Merge branch 'stable-3.0' into stable-3.1

* stable-3.0:
  CreateChange: Clarify the e2e test access modifier
  e2e-tests: Fix minor comma styling inconsistencies
  Set version to 2.16.19

Change-Id: I461192a486780f5857fd5abbb1e2c094e2d938dd
diff --git a/.gitmodules b/.gitmodules
index 6844f6a..9f67e77 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
+[submodule "modules/jgit"]
+	path = modules/jgit
+	url = ../jgit
+
 [submodule "plugins/codemirror-editor"]
 	path = plugins/codemirror-editor
 	url = ../plugins/codemirror-editor
diff --git a/.gitreview b/.gitreview
index b3c37ad..9df7aae 100644
--- a/.gitreview
+++ b/.gitreview
@@ -2,4 +2,4 @@
 host=gerrit-review.googlesource.com
 scheme=https
 project=gerrit.git
-defaultbranch=stable-3.0
+defaultbranch=stable-3.1
diff --git a/.mailmap b/.mailmap
index 38c2a5f..721f3c0 100644
--- a/.mailmap
+++ b/.mailmap
@@ -4,6 +4,7 @@
 Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Alice Kober-Sotzek <aliceks@google.com>                                                     <aliceks@google.com>
 Alexandre Philbert <alexandre.philbert@ericsson.com>                                        <alexandre.philbert@hotmail.com>
 Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
 Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
@@ -14,6 +15,7 @@
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
 Chad Horohoe <chorohoe@wikimedia.org>                                                       <chadh@wikimedia.org>
 Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
+Cheng Ke <chengke.info@gmail.com>                                                           <chengke.info@gmail.com>
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
 Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
 Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
@@ -73,6 +75,7 @@
 Réda Housni Alaoui <reda.housnialaoui@gmail.com>                                            <alaoui.rda@gmail.com>
 Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
 Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
+Sam Saccone <samccone@google.com>                                                           <samccone@google.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
diff --git a/.zuul.yaml b/.zuul.yaml
index 463bc51..fe9dc80 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -6,7 +6,8 @@
 
       This adds required projects needed for all Gerrit-related builds
       (i.e., builds of Gerrit itself or plugins) on this branch.
-    # No additional required projects required for this branch.
+    required-projects:
+      - jgit
 
 - job:
     name: gerrit-build
diff --git a/BUILD b/BUILD
index 3989a75..c48b3b9 100644
--- a/BUILD
+++ b/BUILD
@@ -4,16 +4,16 @@
 package(default_visibility = ["//visibility:public"])
 
 config_setting(
-    name = "java9",
+    name = "java11",
     values = {
-        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java9",
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java11",
     },
 )
 
 config_setting(
     name = "java_next",
     values = {
-        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_vanilla",
+        "java_toolchain": "//tools:toolchain_vanilla",
     },
 )
 
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index cdf6b30..d333347 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -727,13 +727,29 @@
 A user must have this access granted in order to see a project, its
 changes, or any of its data.
 
-This category has a special behavior, where the per-project ACL is
-evaluated before the global all projects ACL.  If the per-project
-ACL has granted `Read` with 'DENY', and does not otherwise grant
-`Read` with 'ALLOW', then a `Read` in the all projects ACL
-is ignored.  This behavior is useful to hide a handful of projects
+[[read_special_behaviors]]
+==== Special behaviors
+
+This category has multiple special behaviors:
+
+The per-project ACL is evaluated before the global all projects ACL.
+If the per-project ACL has granted `Read` with 'DENY', and does not
+otherwise grant `Read` with 'ALLOW', then a `Read` in the all projects
+ACL is ignored.  This behavior is useful to hide a handful of projects
 on an otherwise public server.
 
+You cannot grant `Read` on the `refs/tags/` namespace.  Visibility to
+`refs/tags/` is derived from `Read` grants on refs namespaces other than
+`refs/tags/`, `refs/changes/`, and `refs/cache-automerge/` by finding
+tags reachable from those refs.  For example, if a tag `refs/tags/test`
+points to a commit on the branch `refs/heads/master`, then allowing
+`Read` access to `refs/heads/master` would also allow access to
+`refs/tags/test`.  If a tag is reachable from multiple refs, allowing
+access to any of those refs allows access to the tag.
+
+[[read_typical_usage]]
+==== Typical usage
+
 For an open source, public Gerrit installation it is common to grant
 `Read` to `Anonymous Users` in the `All-Projects` ACL, enabling
 casual browsing of any project's changes, as well as fetching any
@@ -911,7 +927,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Code-Review`] with range '-1' to '+1' for 'refs/heads/*'
 
@@ -939,7 +955,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * xref:category_push_merge[`Push merge commit`] to 'refs/for/refs/heads/*'
 * xref:category_forge_author[`Forge Author Identity`] to 'refs/heads/*'
@@ -994,7 +1010,7 @@
 
 Suggested access rights to grant, that won't block changes:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * link:config-labels.html#label_Code-Review[`Label: Code-Review`] with range '-1' to '0' for 'refs/heads/*'
 * link:config-labels.html#label_Verified[`Label: Verified`] with range '0' to '+1' for 'refs/heads/*'
 
diff --git a/Documentation/backup.txt b/Documentation/backup.txt
index ed044ba..7220c74 100644
--- a/Documentation/backup.txt
+++ b/Documentation/backup.txt
@@ -139,11 +139,12 @@
 server is read-only or down as short as possible.
 
 [#cons-backup-read-only]
-=== Turn master read-only for backup
+=== Turn primary server read-only for backup
 
-Make the server read-only before taking the backup. This means read-access
-is still available during backup, because only write operations have to be
-stopped to ensure consistency. This can be implemented using the
+Make the primary server handling write operations read-only before taking the
+backup. This means read-access is still available from replica servers during
+backup, because only write operations have to be stopped to ensure consistency.
+This can be implemented using the
 link:https://gerrit.googlesource.com/plugins/readonly/[_readonly_] plugin.
 
 [#cons-backup-replicate]
@@ -162,9 +163,9 @@
 Best you use a filesystem supporting snapshots to create a backup archive
 of such a replica.
 
-For 2.x Gerrit versions also set up a database slave for the data stored in the
+For 2.x Gerrit versions also set up a database replica for the data stored in the
 SQL database. If you are using 2.16 and migrated to _NoteDb_ you may consider to
-skip setting up a database slave, instead take a backup of the database which only
+skip setting up a database replica, instead take a backup of the database which only
 contains the current schema version in this case.
 In addition you need to ensure that no write operations are in flight before you
 take the replica offline. Otherwise the database backup might be inconsistent
@@ -176,15 +177,16 @@
 link:https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md[server option]
 `remote.NAME.replicateProjectDeletions`.
 
-If you are using Gerrit slaves to offload read traffic you can use one of these
-slaves for creating backups.
+If you are using Gerrit replica to offload read traffic you can use one of these
+replica for creating backups.
 
 [#cons-backup-offline]
-=== Take master offline for backup
+=== Take primary server offline for backup
 
-Shutdown the server before taking a backup. This is simple but means downtime
-for the users. Also crons and currently running cron jobs (e.g. repacking
-repositories) which affect the repositories may need to be shut down.
+Shut down the primary server handling write operations before taking a backup.
+This is simple but means downtime for the users. Also crons and currently
+running cron jobs (e.g. repacking repositories) which affect the repositories
+may need to be shut down.
 
 [#backup-methods]
 == Backup methods
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index fb35dc2..1dd6720 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -122,16 +122,16 @@
 $ ssh -p 29418 review.example.com gerrit ls-projects
 platform/manifest
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 
 $ curl http://review.example.com/projects/
 platform/manifest
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 
 $ curl http://review.example.com/projects/tools/
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 ----
 
 Clone any project visible to the user:
diff --git a/Documentation/cmd-plugin-remove.txt b/Documentation/cmd-plugin-remove.txt
index f5fe56b..012bf7b 100644
--- a/Documentation/cmd-plugin-remove.txt
+++ b/Documentation/cmd-plugin-remove.txt
@@ -20,6 +20,7 @@
 * Caller must be a member of the privileged 'Administrators' group.
 * link:config-gerrit.html#plugins.allowRemoteAdmin[plugins.allowRemoteAdmin]
 must be enabled in `$site_path/etc/gerrit.config`.
+* Mandatory plugin cannot be disabled
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index addada11..90150b1 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -185,8 +185,6 @@
 link:user-review-ui.html#diff-preferences[diff] and edit preferences:
 
 ----
-[general]
-  showSiteHeader = false
 [diff]
   hideTopMenu = true
 [edit]
@@ -412,10 +410,10 @@
 allows to plug in alternate implementations for storing the reviewed
 flags. To replace the storage for reviewed flags a plugin needs to
 implement the link:dev-plugins.html#account-patch-review-store[
-AccountPatchReviewStore] interface. E.g. to support a multi-master
-setup where reviewed flags should be replicated between the master
-nodes one could implement a store for the reviewed flags that is
-based on MySQL with replication.
+AccountPatchReviewStore] interface. E.g. to support a cluster setup with
+multiple primary servers handling write operations where reviewed flags should
+be replicated between the primary nodes one could implement a store for the
+reviewed flags that is based on MySQL with replication.
 
 [[account-sequence]]
 == Account Sequence
@@ -443,7 +441,7 @@
 * `refs/meta/external-ids` (external IDs)
 * `refs/starred-changes/*` (star labels)
 * `refs/sequences/accounts` (account sequence numbers, not needed for Gerrit
-  slaves)
+  replicas)
 
 GERRIT
 ------
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 2133eb5..b8b66ef 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -833,7 +833,8 @@
 +
 Default value is 0 (disabled). It is disabled by default due to the fact
 that change updates are not communicated between Gerrit servers. Hence
-this cache should be disabled in an multi-master/multi-slave setup.
+this cache should be disabled in a cluster setup using multiple primary
+or multiple replica nodes.
 +
 The cache should be flushed whenever the database changes table is modified
 outside of Gerrit.
@@ -881,6 +882,12 @@
 away from the defaults. The cache may be persisted by setting
 `diskLimit`, which is only recommended if cold start performance is
 problematic.
++
+`external_ids_map` supports computing the new cache value based on a
+previously cached state. This applies modifications based on the Git
+diff and is almost always faster.
+`cache.external_ids_map.enablePartialReloads` turns this behavior on
+or off. The default is `true`.
 
 cache `"git_tags"`::
 +
@@ -1158,17 +1165,6 @@
 +
 Default is true.
 
-[[change.allowDrafts]]change.allowDrafts::
-+
-Legacy support for drafts workflow. If set to true, pushing a new change
-with draft option will create a private change. Pushing with draft option
-to an existing change will create change edit.
-+
-Enabling this option allows to push to the `refs/drafts/branch`. When
-disabled any push to `refs/drafts/branch` will be rejected.
-+
-Default is false.
-
 [[change.api.excludeMergeableInChangeInfo]]change.api.excludeMergeableInChangeInfo::
 +
 If true, the mergeability bit in
@@ -1210,6 +1206,25 @@
 +
 By default 500.
 
+[[change.maxUpdates]]change.maxUpdates::
++
+Maximum number of updates to a change. Counts only updates to the main NoteDb
+meta ref; draft comments, robot comments, stars, etc. do not count towards the
+total.
++
+Many NoteDb operations require walking the entire change meta ref and loading
+its contents into memory, so changes with arbitrarily many updates may cause
+high CPU usage, memory pressure, persistent cache bloat, and other problems.
++
+The following operations are allowed even when a change is at the limit:
+* Abandon
+* Submit
+* Submit by push with `%submit`
+* Auto-close by pushing directly to the branch
+* Fix with link:rest-api-changes.html#fix-input[`expect_merged_as`]
++
+By default 1000.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
@@ -1547,11 +1562,16 @@
 Execute `java -jar gerrit.war daemon --help` to see all possible
 options.
 
+[[container.replica]]container.replica::
++
+Used on Gerrit replica installations. If set to true the Gerrit JVM is
+called with the '--replica' switch, enabling replica mode. If no value is
+set (or any other value), Gerrit defaults to primary mode enabling write
+operations.
+
 [[container.slave]]container.slave::
 +
-Used on Gerrit slave installations. If set to true the Gerrit JVM is
-called with the '--slave' switch, enabling slave mode. If no value is
-set (or any other value), Gerrit defaults to master mode.
+Backward compatibility for 'container.slave' config setting.
 
 [[container.startupTimeout]]container.startupTimeout::
 +
@@ -1578,6 +1598,10 @@
 [[core]]
 === Section core
 
+[NOTE]
+The link:#jgitConfig[etc/jgit.config] file supports configuration of all JGit
+options.
+
 [[core.packedGitWindowSize]]core.packedGitWindowSize::
 +
 Number of bytes of a pack file to load into memory in a single
@@ -2806,28 +2830,28 @@
 ==== Subsection index.scheduledIndexer
 
 This section configures periodic indexing. Periodic indexing is
-intended to run only on slaves and only updates the group index.
-Replication to slaves happens on Git level so that Gerrit is not aware
-of incoming replication events. But slaves need an updated group index
+intended to run only on replicas and only updates the group index.
+Replication to replicas happens on Git level so that Gerrit is not aware
+of incoming replication events. But replicas need an updated group index
 to resolve memberships of users for ACL validation. To keep the group
-index in slaves up-to-date the Gerrit slave periodically scans the
+index in replicas up-to-date the Gerrit replica periodically scans the
 group refs in the All-Users repository to reindex groups if they are
 stale.
 
 The scheduled reindexer is not able to detect group deletions that
-happened while the slave was offline, but since group deletions are not
+happened while the replica was offline, but since group deletions are not
 supported this should never happen. If nevertheless groups refs were
-deleted while a slave was offline a full offline link:pgm-reindex.html[
+deleted while a replica was offline a full offline link:pgm-reindex.html[
 reindex] must be performed.
 
-This section is only used if Gerrit runs in slave mode, otherwise it is
+This section is only used if Gerrit runs in replica mode, otherwise it is
 ignored.
 
 [[index.scheduledIndexer.runOnStartup]]index.scheduledIndexer.runOnStartup::
 +
 Whether the scheduled indexer should run once immediately on startup.
-If set to `true` the slave startup is blocked until all stale groups
-were reindexed. Enabling this allows to prevent that slaves that were
+If set to `true` the replica startup is blocked until all stale groups
+were reindexed. Enabling this allows to prevent that replicas that were
 offline for a longer period of time run with outdated group information
 until the first scheduled indexing is done.
 +
@@ -2837,7 +2861,7 @@
 +
 Whether the scheduled indexer is enabled. If the scheduled indexer is
 disabled you must implement other means to keep the group index for the
-slave up-to-date (e.g. by using ElasticSearch for the indexes).
+replica up-to-date (e.g. by using ElasticSearch for the indexes).
 +
 Defaults to `true`.
 
@@ -3044,6 +3068,31 @@
 +
 Not set by default.
 
+[[event]]
+=== Section event
+
+[[event.payload.listChangeOptions]]events.payload.listChangeOptions::
++
+List of options that Gerrit applies when rendering the payload of an
+internal event. This is the same set of options that are documented
+link:rest-api-changes.html#query-options[here].
++
+Depending on the setup, these events might get serialized using stream
+events.
++
+This can be set to the set of minimal options that consumers of Gerrit's
+events need. A minimal set would be (`SKIP_MERGEABLE`,`SKIP_DIFFSTAT`).
++
+Every option that gets added here will have a performance impact. The
+general recommendation is therefore to set this to a minimal set of
+required options.
++
+Defaults to all available options minus `CHANGE_ACTIONS`,
+`CURRENT_ACTIONS` and `CHECK`. This is a rich default to make sure the
+config is backwards compatible with what the default was before the config
+was added.
+
+
 [[ldap]]
 === Section ldap
 
@@ -3607,6 +3656,14 @@
 and SSH.  If set to true Administrators can install new plugins
 remotely, or disable existing plugins.  Defaults to false.
 
+[[plugins.mandatory]]plugins.mandatory::
++
+List of mandatory plugins. If a plugin from this list does not load,
+Gerrit will fail to start.
++
+Disabling and restarting of a mandatory plugin is rejected, but reloading
+of a mandatory plugin is still possible.
+
 [[plugins.jsLoadTimeout]]plugins.jsLoadTimeout::
 +
 Set the timeout value for loading JavaScript plugins in Gerrit UI.
@@ -3629,17 +3686,6 @@
 If no groups are added, any user will be allowed to execute
 'receive-pack' on the server.
 
-[[receive.allowPushToRefsChanges]]receive.allowPushToRefsChanges::
-+
-If true, it is possible to push directly to a change using `refs/changes/`.
-The possibility to push to `refs/changes/` is deprecated and it might be
-removed in future releases.
-See link:user-upload.html#manual_replacement_mapping[Manual Replacement Mapping].
-+
-False means pushing to `refs/changes/` is prohibited.
-+
-Defaults to false.
-
 [[receive.certNonceSeed]]receive.certNonceSeed::
 +
 If set to a non-empty value and server-side signed push validation is
@@ -3922,6 +3968,14 @@
 Defaults to link:#retry.timeout[`retry.timeout`]; unit suffixes are supported,
 and assumes milliseconds if not specified.
 
+[[retry.retryWithTraceOnFailure]]retry.retryWithTraceOnFailure::
++
+Whether Gerrit should automatically retry operations on failure with tracing
+enabled. The automatically generated traces can help with debugging. Please
+note that only some of the REST endpoints support automatic retry.
++
+By default this is set to false.
+
 [[rules]]
 === Section rules
 
@@ -4338,8 +4392,8 @@
 SSH-compression since git does not compress the ref announcement during
 handshake.
 +
-Compression can be especially useful when Gerrit slaves are being used
-for the larger clones and fetches and the master server mostly takes
+Compression can be especially useful when Gerrit replicas are being used
+for the larger clones and fetches and the primary server mostly takes
 small receive-packs.
 +
 By default, `false`.
@@ -4679,6 +4733,72 @@
 +
 By default 0.
 
+[[tracing]]
+=== Section tracing
+
+[[tracing.performanceLogging]]tracing.performanceLogging::
++
+Whether performance logging is enabled.
++
+When performance logging is enabled, performance events for some
+operations are collected in memory while a request is running. At the
+end of the request the performance events are handed over to the
+link:dev-plugins.html#performance-logger[PerformanceLogger] plugins.
+This means if performance logging is enabled, the memory footprint of
+requests is slightly increased.
++
+This setting has no effect if no
+link:dev-plugins.html#performance-logger[PerformanceLogger] plugins are
+installed, because then performance logging is always disabled.
++
+By default, true.
+
+[[tracing.traceid]]
+==== Subsection tracing.<trace-id>
+
+There can be multiple `tracing.<trace-id>` subsections to configure
+automatic tracing of requests. To be traced a request must match all
+conditions of one `tracing.<trace-id>` subsection. The subsection name
+is used as trace ID. Using this trace ID administrators can find
+matching log entries.
+
+[[tracing.traceid.requestType]]tracing.<trace-id>.requestType::
++
+Type of request for which request tracing should be always enabled (can
+be `GIT_RECEIVE`, `GIT_UPLOAD`, `REST` and `SSH`).
++
+May be specified multiple times.
++
+By default, unset (all request types are matched).
+
+[[tracing.traceid.requestUriPattern]]tracing.<trace-id>.requestUriPattern::
++
+Regular expression to match request URIs for which request tracing
+should be always enabled. Request URIs are only available for REST
+requests. Request URIs never include the '/a' prefix.
++
+May be specified multiple times.
++
+By default, unset (all request URIs are matched).
+
+[[tracing.traceid.account]]tracing.<trace-id>.account::
++
+Account ID of an account for which request tracing should be always
+enabled.
++
+May be specified multiple times.
++
+By default, unset (all accounts are matched).
+
+[[tracing.traceid.projectPattern]]tracing.<trace-id>.projectPattern::
++
+Regular expression to match project names for which request tracing
+should be always enabled.
++
+May be specified multiple times.
++
+By default, unset (all projects are matched).
+
 [[trackingid]]
 === Section trackingid
 
@@ -5032,6 +5152,19 @@
 +
 * link:config-themes.html[Themes]
 
+[[jgitConfig]]
+== File `etc/jgit.config`
+
+Gerrit uses the `$site_path/etc/jgit.config` file instead of the
+system-wide and user-global Git configuration for its runtime JGit
+configuration.
+
+Sample `etc/jgit.config` file:
+----
+[core]
+  trustFolderStat = false
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 4db4cb3..afabbfc 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -103,7 +103,7 @@
 
 == Replication
 
-In a replicated setting (eg. backups and or master/slave
-configurations), all refs in the `All-Users` project must be copied
-onto all replicas, including `refs/groups/*`, `refs/meta/group-names`
-and `refs/sequences/groups`.
+In a replicated setting (eg. backups and or primary/replica configurations), all
+refs in the `All-Users` project on primary nodes must be copied onto all
+replicas, including `refs/groups/*`, `refs/meta/group-names` and
+`refs/sequences/groups`.
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index ff43520..193a96f 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -262,6 +262,12 @@
 
 Defaults to true.
 
+[[label_copyAnyScore]]
+=== `label.Label-Name.copyAnyScore`
+
+If true, any score for the label is copied forward when a new patch
+set is uploaded. Defaults to false.
+
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
@@ -297,12 +303,13 @@
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that is a trivial rebase. A new patch set is considered
-as trivial rebase if the commit message is the same as in the previous
-patch set and if it has the same code delta as the previous patch set.
-This is the case if the change was rebased onto a different parent, or
-if the parent did not change at all.
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that is a trivial rebase. A new patch set is considered to be trivial
+rebase if the commit message is the same as in the previous patch set and if it
+has the same diff (including context lines) as the previous patch set. This is
+the case if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent did not
+change at all.
 
 This can be used to enable sticky approvals, reducing turn-around for
 trivial rebases prior to submitting a change.
@@ -313,13 +320,13 @@
 [[label_copyAllScoresIfNoCodeChange]]
 === `label.Label-Name.copyAllScoresIfNoCodeChange`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree as the previous patch
-set and the same code delta as the previous patch set. This means only
-the commit message is different. This can be used to enable sticky
-approvals on labels that only depend on the code, reducing turn-around
-if only the commit message is changed prior to submitting a change.
-For the Verified label that is optionally installed by the
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that has the same parent tree as the previous patch set and the same
+code diff (including context lines) as the previous patch set. This means only
+the commit message is different; the change hasn't even been rebased. This can
+be used to enable sticky approvals on labels that only depend on the code,
+reducing turn-around if only the commit message is changed prior to submitting a
+change. For the Verified label that is optionally installed by the
 link:pgm-init.html[init] site program this is enabled by default.
 
 Defaults to false.
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index 3dcef0a..cc2185b 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -57,7 +57,7 @@
 find the url in the settings file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config gerrit.canonicalWebUrl
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config gerrit.canonicalWebUrl
   http://localhost:8080/
   gerrit@host:~$
 ----
@@ -70,9 +70,9 @@
 proxy settings in the configuration file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxy http://proxy:8080
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyUsername username
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyPassword password
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxy http://proxy:8080
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyUsername username
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyPassword password
 ----
 
 Refer to the Gerrit configuration guide for more detailed information about
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
index 0077697..f5185a4 100644
--- a/Documentation/config-robot-comments.txt
+++ b/Documentation/config-robot-comments.txt
@@ -36,7 +36,6 @@
 
 == Limitations
 
-* Robot comments are not displayed in the web UI yet.
 * There is no support for draft robot comments, but robot comments are
   always published and visible to everyone who can see the change.
 
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 24932a8..cb953c1 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -122,6 +122,15 @@
 perform validation when an account is activated or deactivated via the Gerrit
 REST API or the Java extension API.
 
+[[review-comment-validation]]
+== Review comment validation
+
+
+The `CommentValidator` interface allows plugins to validate all review comments,
+i.e. inline comments, file comments and the review message. This works for the
+REST API, for `git push` when `--publish-comments` is used and for comments sent
+via email.
+
 
 GERRIT
 ------
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index d5437d6..498856e 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -8,7 +8,8 @@
 * A Linux or macOS system (Windows is not supported at this time)
 * A JDK for Java 8|9|10|11|...
 * Python 2 or 3
-* Node.js
+* link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm)]
+* Bower (`sudo npm install -g bower`)
 * link:https://docs.bazel.build/versions/master/install.html[Bazel] -launched with
 link:https://github.com/bazelbuild/bazelisk[Bazelisk]
 * Maven
@@ -29,17 +30,31 @@
 [[java]]
 === Java
 
-[[java-10]]
-==== Java 10 support
+==== MacOS
 
-Java 10 (and newer) is supported through vanilla java toolchain
+On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
+and that `JAVA_HOME` is set to the
+link:install.html#Requirements[required Java version].
+
+Java installations can typically be found in
+"/System/Library/Frameworks/JavaVM.framework/Versions".
+
+To check the installed version of Java, open a terminal window and run:
+
+`java -version`
+
+[[java-12]]
+==== Java 12 support
+
+Java 12 (and newer) is supported through vanilla java toolchain
 link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 10 and newer, specify vanilla java toolchain and
+To build Gerrit with Java 12 and newer, specify vanilla java toolchain and
 provide the path to JDK home:
 
 ```
   $ bazel build \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-12> \
+    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
     --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
@@ -51,7 +66,7 @@
 
 ```
   $ bazel test \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-12> \
     --javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
@@ -64,7 +79,7 @@
 
 ```
 $ cat << EOF > ~/.bazelrc
-> build --define=ABSOLUTE_JAVABASE=<path-to-java-10>
+> build --define=ABSOLUTE_JAVABASE=<path-to-java-12>
 > build --javabase=@bazel_tools//tools/jdk:absolute_javabase
 > build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
 > build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
@@ -75,36 +90,24 @@
 Now, invoking Bazel with just `bazel build :release` would include
 all those options.
 
-Note that the follow option must be added to `container.javaOptions`
-in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10|11|...:
+[[java-11]]
+==== Java 11 support
 
-```
-[container]
-  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
-```
-
-[[java-9]]
-==== Java 9 support
-
-Java 9 is supported through alternative java toolchain
+Java 11 is supported through alternative java toolchain
 link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-The Java 9 support is backwards compatible. Java 8 is still the default.
-To build Gerrit with Java 9, specify JDK 9 java toolchain:
+To build Gerrit with Java 11, specify JDK 11 java toolchain:
 
 ```
   $ bazel build \
-      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
-      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
+      --host_javabase=@bazel_tools//tools/jdk:remote_jdk11 \
+      --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
+      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
+      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
       :release
 ```
 
-Note that the follow option must be added to `container.javaOptions`
-in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 9:
-
-```
-[container]
-  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
-```
+=== Node.js and npm packages
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages].
 
 [[build]]
 == Building on the Command Line
@@ -117,10 +120,6 @@
   bazel build gerrit
 ----
 
-[NOTE]
-PolyGerrit UI may require additional tools (such as npm). Please read
-the polygerrit-ui/README.md for more info.
-
 The output executable WAR will be placed in:
 
 ----
@@ -219,13 +218,6 @@
 Note that when building an individual plugin, the `core.zip` package
 is not regenerated.
 
-To build with all Error Prone warnings activated, run:
-
-----
-  bazel build --java_toolchain //tools:error_prone_warnings_toolchain //...
-----
-
-
 [[IDEs]]
 == Using an IDE.
 
@@ -323,6 +315,12 @@
   bazel test --test_tag_filters=-docker //...
 ----
 
+To exclude tests that require very recent git client version:
+
+----
+  bazel test --test_tag_filters=-git-protocol-v2 //...
+----
+
 To ignore cached test results:
 
 ----
@@ -343,6 +341,7 @@
 * edit
 * elastic
 * git
+* git-protocol-v2
 * git-upload-archive
 * notedb
 * pgm
@@ -419,16 +418,16 @@
 
 == Building against unpublished Maven JARs
 
-To build against unpublished Maven JARs, like gwtorm or PrologCafe, the custom
-JARs must be installed in the local Maven repository (`mvn clean install`) and
+To build against unpublished Maven JARs, like PrologCafe, the custom JARs must
+be installed in the local Maven repository (`mvn clean install`) and
 `maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for
 that artifact:
 
 [source,python]
 ----
  maven_jar(
-   name = 'gwtorm',
-   artifact = 'gwtorm:gwtorm:42',
+   name = 'prolog-runtime',
+   artifact = 'com.googlecode.prolog-cafe:prolog-runtime:42',
    repository = MAVEN_LOCAL,
  )
 ----
@@ -475,11 +474,18 @@
  )
 ----
 
-[[consume-jgit-from-development-tree]]
+== Building against SNAPSHOT Maven JARs
 
-To consume the JGit dependency from the development tree, edit
-`lib/jgit/jgit.bzl` setting LOCAL_JGIT_REPO to a directory holding a
-JGit repository.
+To build against SNAPSHOT Maven JARs, the complete SNAPSHOT version must be used:
+
+[source,python]
+----
+ maven_jar(
+   name = "pac4j-core",
+   artifact = "org.pac4j:pac4j-core:3.5.0-SNAPSHOT-20190112.120241-16",
+   sha1 = "da2b1cb68a8f87bfd40813179abd368de9f3a746",
+ )
+----
 
 [[bazel-local-caches]]
 
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 60c6b9d..219861f 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -75,6 +75,12 @@
 Some plugins describe their build process in `src/main/resources/Documentation/build.md`
 file. It may worth checking.
 
+=== Error Prone checks
+
+Error Prone checks are enabled by default for core Gerrit and all core plugins. To
+enable the checks for custom plugins, add it in the `error_prone_packages` group
+in `tools/BUILD`.
+
 === Plugins with external dependencies ===
 
 If the plugin has external dependencies, then they must be included from Gerrit's
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
new file mode 100644
index 0000000..5bc34a7
--- /dev/null
+++ b/Documentation/dev-cla.txt
@@ -0,0 +1,26 @@
+= Gerrit Code Review - Contributor License Agreement
+
+In order to link:dev-community.html#how-to-contribute[contribute] to
+Gerrit a Contributor License Agreement must be completed before
+contributions are accepted. To view and accept the agreements do the
+following:
+
+. Click 'Sign In' at the top right corner of
+  https://gerrit-review.googlesource.com/
+. Sign In with your Google account
+. After signing in, go to the
+  link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
+  tab on the settings page
+. Click on 'New Contributor Agreement' and follow the instructions
+
+For reference, the actual agreements are linked below:
+
+* link:https://cla.developers.google.com/about/google-individual[Individual Agreement]
+* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
new file mode 100644
index 0000000..4fb025f
--- /dev/null
+++ b/Documentation/dev-community.txt
@@ -0,0 +1,70 @@
+= Gerrit Community
+
+Gerrit is developed as a
+link:https://gerrit-review.googlesource.com/[self-hosting open source project]
+and very much welcomes contributions from anyone with a
+link:dev-cla.html[contributor's agreement] on file with the project.
+
+[[project-information]]
+== Project Information
+
+* link:https://www.gerritcodereview.com/[Project Homepage]
+* link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct]
+* link:https://www.gerritcodereview.com/releases-readme.html[Release Versions]
+* link:https://gerrit.googlesource.com/gerrit[Source]
+* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
+* link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review]
+* link:dev-design.html[System Design]
+* Processes
+** link:dev-processes.html#project-governance[Project Governance / Engineering Steering Committee]
+** link:#how-to-contribute[Contribution Processes]
+** link:dev-design-docs.html#review[Design doc reviews]
+** link:dev-processes.html#dev-in-stable-branches[Development in stable branches]
+** link:dev-processes.html#backporting[Backporting to stable branches]
+** link:dev-processes.html#security-issues[Dealing with Security Issues]
+** link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+** link:dev-processes.html#deprecating-features[Deprecating features]
+* Roles
+** link:dev-roles.html#supporter[Supporter]
+** link:dev-roles.html#contributor[Contributor]
+** link:dev-roles.html#maintainer[Maintainer]
+** link:dev-roles.html#mentor[Mentor]
+** link:dev-roles.html#steering-committee-member[Engineering Steering Committee Member]
+** link:dev-roles.html#community-manager[Community Manager]
+** link:dev-roles.html#release-manager[Release Manager]
+
+[[how-to-contribute]]
+== How to Contribute
+* link:dev-cla.html[Contributor License Agreement]
+* link:dev-contributing.html#contribution-processes[Contribution Processes]
+** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
+** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
+** link:dev-contributing.html#mentorship[Mentorship]
+* link:dev-design-docs.html[Design Docs]
+* link:dev-readme.html[Developer Setup]
+* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[Polymer Frontend Developer Setup]
+* link:dev-crafting-changes.html[Crafting Changes]
+* link:dev-starter-projects.html[Starter Projects]
+
+[[plugin-development]]
+== Plugin Development
+* link:dev-plugins-lifecycle.html[Plugin Lifecycle]
+* link:dev-plugins.html[Developing Plugins]
+* link:dev-build-plugins.html[Building Gerrit plugins]
+* link:js-api.html[JavaScript Plugin API]
+* link:config-validation.html[Validation Interfaces]
+* link:dev-stars.html[Starring Changes]
+* link:quota.html[Quota Enforcement]
+
+[[maintainer]]
+== Maintainer
+* link:dev-release.html[Making a Gerrit Release]
+* link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
+* link:https://www.gerritcodereview.com/publishing.html[Publish Gerrit Homepage]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index ed2561a..23ecd67 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -1,410 +1,338 @@
+:linkattrs:
 = Gerrit Code Review - Contributing
 
-== Introduction
-Gerrit is developed as a
-link:https://gerrit-review.googlesource.com/[self-hosting open source project]
-and very much welcomes contributions from anyone with a contributor's
-agreement on file with the project.
-
+[[cla]]
 == Contributor License Agreement
-A Contributor License Agreement must be completed before contributions
-are accepted.  To view and accept the agreements do the following:
 
-* Click 'Sign In' at the top right corner of https://gerrit-review.googlesource.com/
-* Sign In with your Google account
-* After signing in, go to the
-link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
-tab on the settings page
-* Click 'New Contributor Agreement' and follow the instructions
+In order to contribute to Gerrit a link:dev-cla.html[Contributor
+License Agreement,role=external,window=_blank] must be completed before
+contributions are accepted.
 
-For reference, the actual agreements are linked below:
+[[contribution-processes]]
+== Contribution Processes
 
-* link:https://cla.developers.google.com/about/google-individual[Individual Agreement]
-* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement]
+The Gerrit project offers two contribution processes:
 
-== Code Review
+* link:#lightweight-contribution-process[Lightweight Contribution
+  Process]
+* link:#design-driven-contribution-process[Design-Driven Contribution
+  Process]
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). Contributions are pushed as changes and
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+review them adhoc.
+
+For large/complex features, it is required to follow the
+link:#design-driven-contribution-process[design-driven contribution
+process] and specify the feature in a link:dev-design-docs.html[design
+doc,role=external,window=_blank] before starting with the implementation.
+
+If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
+choose the lightweight contribution process and during the review it turns out
+that the feature is too large or complex,
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
+require to follow the design-driven contribution process instead.
+
+If you are in doubt which process is right for you, consult the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
+mailing list.
+
+These contribution processes apply to everyone who contributes code to
+the Gerrit project, including link:dev-roles.html#maintainer[
+maintainers,role=external,window=_blank]. When reading this document, keep in
+mind that maintainers are also contributors when they contribute code.
+
+If a new feature is large or complex, it is often difficult to find a
+maintainer who can take the time that is needed for a thorough review,
+and who can help with getting the changes submitted. To avoid that this
+results in unpredictable long waiting times during code review,
+contributors can ask for link:#mentorship[mentor support]. A mentor
+helps with timely code reviews and technical guidance. Doing the
+implementation is still the responsibility of the contributor.
+
+[[comparison]]
+=== Quick Comparison
+
+[options="header"]
+|======================
+|        |Lightweight Contribution Process|Design-Driven Contribution Process
+|Overhead|low (write good commit message, address review comments)|
+high (write link:dev-design-docs.html[design doc,role=external,window=_blank]
+and get it approved)
+|Technical Guidance|by reviewer|during the design review and by
+reviewer/mentor
+|Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
+a link:#mentorship[mentor] was assigned)
+|Caveats |features may get vetoed after the implementation was already
+done, maintainers may make the design-driven contribution process
+required if a change gets too complex/large|design doc must stay open
+for a minimum of 10 calendar days, a mentor may not be available
+immediately
+|Applicable to|documentation updates, bug fixes, small features|
+large/complex features
+|======================
+
+[[lightweight-contribution-process]]
+=== Lightweight Contribution Process
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). For large/complex features the
+link:#design-driven-contribution-process[design-driven contribution
+process] is required.
+
 As Gerrit is a code review tool, naturally contributions will
 be reviewed before they will get submitted to the code base.  To
 start your contribution, please make a git commit and upload it
-for review to the main Gerrit review server.  To help speed up the
-review of your change, review these guidelines before submitting
-your change.  You can view the pending Gerrit contributions and
-their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here].
+for review to the link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
+help speed up the review of your change, review these link:dev-crafting-changes.html[
+guidelines,role=external,window=_blank] before submitting your change.  You can
+view the pending Gerrit contributions and their statuses
+link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
 your change to get reviewed.  Naturally there are fewer
-approvers than contributors; so anything that you can do to
-ensure that your contribution will undergo fewer revisions
-will speed up the contribution process.  This includes helping
-out reviewing other people's changes to relieve the load from
-the approvers.  Even if you are not familiar with Gerrit's
-internals, it would be of great help if you can download, try
-out, and comment on new features.  If it works as advertised,
-say so, and if you have the privileges to do so, go ahead
-and give it a +1 Verified.  If you would find the feature
-useful, say so and give it a +1 code review.
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
+can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
+so anything that you can do to ensure that your contribution will undergo fewer
+revisions will speed up the contribution process.  This includes
+helping out reviewing other people's changes to relieve the load from
+the maintainers.  Even if you are not familiar with Gerrit's internals,
+it would be of great help if you can download, try out, and comment on
+new features.  If it works as advertised, say so, and if you have the
+privileges to do so, go ahead and give it a `+1 Verified`.  If you
+would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your
-reviewers, the quicker your change might get merged!  Try to
-reply to every comment after submitting your new patch,
-particularly if you decided against making the suggested change.
-Reviewers don't want to seem like nags and pester you if you
-haven't replied or made a fix, so it helps them know if you
-missed it or decided against it.
+And finally, the quicker you respond to the comments of your reviewers,
+the quicker your change might get merged!  Try to reply to every
+comment after submitting your new patch, particularly if you decided
+against making the suggested change. Reviewers don't want to seem like
+nags and pester you if you haven't replied or made a fix, so it helps
+them know if you missed it or decided against it.
 
+[[design-driven-contribution-process]]
+=== Design-driven Contribution Process
 
-== Review Criteria
+The design-driven contribution process applies to large/complex
+features.
 
-Here are some hints as to what approvers may be looking for
-before approving or submitting changes to the Gerrit project.
-Let's start with the simple nit picky stuff.  You are likely
-excited that your code works; help us share your excitement
-by not distracting us with the simple stuff.  Thanks to Gerrit,
-problems are often highlighted and we find it hard to look
-beyond simple spacing issues.  Blame it on our short attention
-spans, we really do want your code.
+For large/complex features it is important to:
 
+* agree on the functionality and scope before spending too much time
+  on the implementation
+* ensure that they are in line with Gerrit's project scope and vision
+* ensure that they are well aligned with other features
+* think about possibilities how the feature could be evolved over time
 
-[[commit-message]]
-=== Commit Message
+This is why for large/complex features it is required to describe the
+feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
+and get it approved by the
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+before starting the implementation.
 
-It is essential to have a good commit message if you want your
-change to be reviewed.
+The design-driven contribution process has the following steps:
 
-  * Keep lines no longer than 72 chars
-  * Start with a short one line summary
-  * Followed by a blank line
-  * Followed by one or more explanatory paragraphs
-  * Use the present tense (fix instead of fixed)
-  * Use the past tense when describing the status before this commit
-  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
-    `Feature: Issue <#>` line if implementing a feature request.
-  * Include a `Change-Id` line
+* A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
+  link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
+  feature by uploading a change with a
+  link:dev-design-docs.html[design doc,role=external,window=_blank].
+* The design doc is link:dev-design-docs.html#review[reviewed,role=external,window=_blank]
+  by interested parties from the community. The design review is public
+  and everyone can comment and raise concerns.
+* Design docs should stay open for a minimum of 10 calendar days so
+  that everyone has a fair chance to join the review.
+* Within 14 calendar days the contributor should hear back from the
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
+  whether the proposed feature is in scope of the project and if it can
+  be accepted.
+* To be submitted, the design doc needs to be approved by the
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
+* After the design was approved, the implementation is done by pushing
+  changes for review, see link:#lightweight-contribution-process[
+  lightweight contribution process]. Changes that are associated with
+  a design should all share a common hashtag. The contributor is the
+  main driver of the implementation and responsible that it is done.
+  Others from the Gerrit community are usually much welcome to help
+  with the implementation.
 
-=== Setting up Vim for Git commit message
+In order to be accepted/submitted, it is not necessary that the design
+doc fully specifies all the details, but the idea of the feature and
+how it fits into Gerrit should be sufficiently clear (judged by the
+steering committee). Contributors are expected to keep the design doc
+updated and fill in gaps while they go forward with the implementation.
+We expect that implementing the feature and updating the design doc
+will be an iterative process.
 
-Git uses Vim as the default commit message editor. Put this into your
-`$HOME/.vimrc` file to configure Vim for Git commit message formatting
-and writing:
+While the design doc is still in review, contributors may already start
+with the implementation (e.g. do some prototyping to demonstrate parts
+of the proposed design), but those changes should not be submitted
+while the design wasn't approved yet.
 
-====
-  " Enable spell checking, which is not on by default for commit messages.
-  au FileType gitcommit setlocal spell
+By approving a design, the steering committee commits to:
 
-  " Reset textwidth if you've previously overridden it.
-  au FileType gitcommit setlocal textwidth=72
-====
+* Accepting the feature when it is implemented.
+* Supporting the feature by assigning a link:dev-roles.html#mentor[
+  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
 
+If the implementation of a feature gets stuck and it's unclear whether
+the feature gets fully done, it should be discussed with the steering
+committee how to proceed. If the contributor cannot commit to finish
+the implementation and no other contributor can take over, changes that
+have already been submitted for the feature might get reverted so that
+there is no unused or half-finished code in the code base.
 
-[[git_commit_settings]]
-=== A sample good Gerrit commit message:
-====
-  Add sample commit message to guidelines doc
+For contributors, the design-driven contribution process has the
+following advantages:
 
-  The original patch set for the contributing guidelines doc did not
-  include a sample commit message, this new patchset does.  Hopefully this
-  makes things a bit clearer since examples can sometimes help when
-  explanations don't.
+* By writing a design doc, the feature gets more attention. During the
+  design review, feedback from various sides can be collected, which
+  likely leads to improvements of the feature.
+* Once a design was approved by the
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+  the contributor can be almost certain that the feature will be accepted.
+  Hence, there is only a low risk to invest into implementing a feature
+  and see it being rejected later during the code review, as it can
+  happen with the lightweight contribution process.
+* The contributor can link:#mentorship[get a dedicated mentor assigned]
+  who provides timely reviews and serves as a contact person for
+  technical questions and discussing details of the design.
 
-  Note that the body of this commit message can be several paragraphs, and
-  that I word wrap it at 72 characters.  Also note that I keep the summary
-  line under 50 characters since it is often truncated by tools which
-  display just the git summary.
+[[mentorship]]
+== Mentorship
 
-  Bug: Issue 98765605
-  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
-====
+For features for which a link:dev-design-docs.html[design,role=external,window=_blank]
+has been approved (see link:#design-driven-contribution-process[design-driven
+contribution process]), contributors can gain the support of a mentor
+if they are committed to implement the feature.
 
-The `Change-Id` line is, as usual, created by a local git hook.  To install it,
-simply copy it from the checkout and make it executable:
+A link:dev-roles.html#mentor[mentor,role=external,window=_blank] helps with:
 
-====
-  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
-  chmod +x .git/hooks/commit-msg
-====
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
 
-If you are working on core plugins, you will also need to install the
-same hook in the submodules:
+A feature can have more than one mentor. To be able to deliver the
+promised support, at least one of the mentors must be a
+link:dev-roles.html#maintainer[maintainer,role=external,window=_blank].
 
-====
-  export hook=$(pwd)/.git/hooks/commit-msg
-  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
-====
+Mentors are assigned by the link:dev-processes.html#steering-committee[
+steering committee,role=external,window=_blank]. To gain a mentor, ask for a
+mentor in the link:dev-design-doc-template.html#implementation-plan[Implementation
+Plan,role=external,window=_blank] section of the design doc or ask the steering
+committee after the design has been approved.
 
+Mentors may not be available immediately. In this case, the steering
+committee should include the approved feature into the roadmap or
+prioritize it in the backlog. This way, it is transparent for the
+contributor when they can expect to be able to work on the feature with
+mentor support.
 
-To set up git's remote for easy pushing, run the following:
+Once the implementation phase starts, the contributor is expected to do
+the implementation in a timely manner.
 
-====
-  git remote add gerrit https://gerrit.googlesource.com/gerrit
-====
+For every mentorship, the end must be clearly defined. The design doc
+must specify:
 
-The HTTPS access requires proper username and password; this can be obtained
-by clicking the 'Obtain Password' link on the
-link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
-Password tab of the user settings page].
+* a maximum time frame for the mentorship, after which the mentorship
+  automatically ends, even if the feature is not done yet
+* done criteria that define when the feature is done and the mentorship
+  ends
 
-Alternately, you may use the
-link:https://pypi.org/project/git-review/[git-review] tool to submit changes
-to Gerrit. If you do, it will set up the Change-Id hook and `gerrit` remote
-for you. You will still need to do the HTTP access step.
+If a feature is not finished in time, it should be discussed with the
+steering committee how to proceed. If the contributor cannot commit to
+finish the implementation in time and no other contributor can take
+over, changes that have already been submitted for the feature might
+get reverted so that there is no unused or half-finished code in the
+code base.
 
-[[style]]
-=== Style
+[[esc-dd-evaluation]]
+== How the ESC evaluates design documents
+This section describes how the ESC evaluates design documents. It’s
+meant as a guideline rather than being prescriptive for both ESC
+members and contributors.
 
-This project has a policy of Eclipse's warning free code. Eclipse
-configuration is added to git and we expect the changes to be
-warnings free.
+=== General Process
+As part of the design process, the ESC makes a final decision if a
+design gets to be implemented. If there are multiple alternative
+solutions, the ESC will decide which solution can be implemented.
 
-We do not ask you to use Eclipse for editing, obviously.  We do ask you
-to provide Eclipse's warning free patches only. If for some reasons, you
-are not able to set up Eclipse and verify, that your patch hasn't
-introduced any new Eclipse warnings, mention this in a comment to your
-change, so that reviewers will do it for you. Yes, the way to go is to
-extend gerrit CI to take care of this, but it's not yet implemented.
+The ESC should wait until all contributors had the chance to
+voice their opinion in review comments or by proposing alternative
+solutions. Due to the infrequent ESC meetings (every 2-4 weeks)
+the ESC might discuss documents in cases where the discussion is
+already advanced far enough, but not make a decision yet. In this
+case, contributors can still voice concerns or discuss alternatives.
+The decision can be at the next meeting or via email in between
+meetings.
 
-Gerrit follows the
-link:https://google.github.io/styleguide/javaguide.html[Google Java Style
-Guide].
+=== Evaluation
+Product/Vision fit
 
-To format Java source code, Gerrit uses the
-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 3.0.0). Unused dependencies are found and removed using the
-link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`]
-build tool, a sibling of `buildifier`.
+Q: `Do we believe this feature belongs to Gerrit Code Review use-cases?`
 
-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.
-
-You may download and run `google-java-format` on your own, or you may
-run `./tools/setup_gjf.sh` to download a local copy and set up a
-wrapper script. If you run your own copy, please use the same version,
-as there may be slight differences between versions.
-
-When to use `final` modifier and when not (in new code):
-
-Always:
-
-  * final fields: marking fields as final forces them to be
-  initialized in the constructor or at declaration
-  * final static fields: clearly communicates the intent
-  * to use final variables in inner anonymous classes
-
-Optional:
-
-  * final classes: use when appropriate, e.g. API restriction
-  * final methods: similar to final classes
-
-Never:
-
-  * local variables: it clutters the code, and makes the code less
-  readable. When copying old code to new location, finals should
-  be removed
-  * method parameters: similar to local variables
-
-=== Code Organization
-
-Do your best to organize classes and methods in a logical way.
-Here are some guidelines that Gerrit uses:
-
-  * Ensure a standard copyright header is included at the top
-    of any new files (copy it from another file, update the year).
-  * Always place loggers first in your class!
-  * Define any static interfaces next in your class.
-  * Define non static interfaces after static interfaces in your
-    class.
-  * Next you should define static types, static members, and
-    static methods, in decreasing order of visibility (public to private).
-  * Finally instance types, instance members, then constructors,
-    and then instance methods.
-  * Some common exceptions are private helper static methods, which
-    might appear near the instance methods which they help (but may
-    also appear at the top).
-  * Getters and setters for the same instance field should usually
-    be near each other barring a good reason not to.
-  * If you are using assisted injection, the factory for your class
-    should be before the instance members.
-  * Annotations should go before language keywords (`final`, `private`, etc) +
-    Example: `@Assisted @Nullable final type varName`
-  * Prefer to open multiple AutoCloseable resources in the same
-    try-with-resources block instead of nesting the try-with-resources
-    blocks and increasing the indentation level more than necessary.
-
-Wow that's a lot!  But don't worry, you'll get the habit and most
-of the code is organized this way already; so if you pay attention
-to the class you are editing you will likely pick up on it.
-Naturally new classes are a little harder; you may want to come
-back and consult this section when creating them.
-
-
-=== Design
-
-Here are some design level objectives that you should keep in mind
-when coding:
-
-  * Most client pages should perform only one RPC to load so as to
-    keep latencies down.  Exceptions would apply to RPCs which need
-    to load large data sets if splitting them out will help the
-    page load faster.  Generally page loads are expected to complete
-    in under 100ms.  This will be the case for most operations,
-    unless the data being fetched is not using Gerrit's caching
-    infrastructure.  In these slower cases, it is worth considering
-    mitigating this longer load by using a second RPC to fill in
-    this data after the page is displayed (or alternatively it might
-    be worth proposing caching this data).
-  * `@Inject` should be used on constructors, not on fields.  The
-    current exceptions are the ssh commands, these were implemented
-    earlier in Gerrit's development.  To stay consistent, new ssh
-    commands should follow this older pattern; but eventually these
-    should get converted to eliminate this exception.
-  * Don't leave repository objects (git or schema) open.  A .close()
-    after every open should be placed in a finally{} block.
-  * Don't leave UI components, which can cause new actions to occur,
-    enabled during RPCs which update Git repositories, including NoteDb.
-    This is to prevent people from submitting actions more than once
-    when operating on slow links.  If the action buttons are disabled,
-    they cannot be resubmitted and the user can see that Gerrit is still
-    busy.
-  * ...and so is Guava (previously known as Google Collections).
-
-
-=== Tests
-
-  * Tests for new code will greatly help your change get approved.
-
-
-=== Change Size/Number of Files Touched
-
-And finally, I probably cannot say enough about change sizes.
-Generally, smaller is better, hopefully within reason.  Do try to
-keep things which will be confusing on their own together,
-especially if changing one without the other will break something!
-
-  * If a new feature is implemented and it is a larger one, try to
-    identify if it can be split into smaller logical features; when
-    in doubt, err on the smaller side.
-  * Separate bug fixes from feature improvements.  The bug fix may
-    be an easy candidate for approval and should not need to wait
-    for new features to be approved.  Also, combining the two makes
-    reviewing harder since then there is no clear line between the
-    fix and the feature.
-  * Separate supporting refactoring from feature changes.  If your
-    new feature requires some refactoring, it helps to make the
-    refactoring a separate change which your feature change
-    depends on.  This way, reviewers can easily review the refactor
-    change as a something that should not alter the current
-    functionality, and feel more confident they can more easily
-    spot errors this way.  Of course, it also makes it easier to
-    test and locate later on if an unfortunate error does slip in.
-    Lastly, by not having to see refactoring changes at the same
-    time, it helps reviewers understand how your feature changes
-    the current functionality.
-  * Separate logical features into separate changes.  This
-    is often the hardest part.  Here is an example:  when adding a
-    new ability, make separate changes for the UI and the ssh
-    commands if possible.
-  * Do only what the commit message describes.  In other words, things which
-    are not strictly related to the commit message shouldn't be part of
-    a change, even trivial things like externalizing a string somewhere
-    or fixing a typo.  This helps keep `git blame` more useful in the future
-    and it also makes `git revert` more useful.
-  * Use topics to link your separate changes together.
-
-[[process]]
-== Process
-
-[[dev-in-stable-branches]]
-=== Development in stable branches
-
-As their name suggests stable branches are intended to be stable. This means that generally
-only bug-fixes should be done on stable branches, however this is not strictly enforced and
-exceptions may apply:
+* Yes: Customizable dashboards
+* No: UI for managing an LDAP server
 
-  * When a stable branch is initially created to prepare a new release the Gerrit community
-    discusses on the mailing list if there are pending features which should still make it into the
-    release. Those features are blocking the release and should be implemented on the stable
-    branch before the first release candidate is created.
-  * To stabilize the code before doing a major release several release candidates are created. Once
-    the first release candidate was done no more features should be accepted on the stable branch.
-    If more features are found to be required they should be discussed with the Gerrit maintainers
-    and should only be allowed if the risk of breaking things is considered to be low.
-  * Once a major release is done only bug-fixes and documentation updates should be done on the
-    stable branch. These updates will be included in the next minor release.
-  * For minor releases new features are only acceptable if they are important to the Gerrit
-    community, if they are backwards compatible and the risk of breaking things is low and if there
-    are no objections from the Gerrit community.
-  * In cases of doubt it's the responsibility of the release maintainer to evaluate the risk of new
-    features and make a decision based on these rules and opinions from the Gerrit community.
-  * The older a stable branch is the more stable it should be. This means old stable branches
-    should only receive bug-fixes that are either important or low risk. Security fixes, including
-    security updates for third party dependencies, are always considered as important and hence can
-    always be done on stable branches.
+Q: `Is the proposed solution aligned with Gerrit’s vision?`
 
-=== Backporting to stable branches
+* Yes: Showing comments of older patch sets in newer patch sets to
+  keep track (core code review)
+* No: Implement a bug tracker in Gerrit (not core code review).
 
-From time to time bug fix releases are made for existing stable branches.
+=== Impact
+Q: `Will the new feature have a measurable, positive impact?`
 
-Developers concerned with stable branches are encouraged to backport or push fixes to these
-branches, even if no new release is planned. Backporting features is only possible in compliance
-with the rules link:#dev-in-stable-branches[above].
+* Yes: Increased productivity, faster/smoother workflow, etc.
+* Yes: Better latency, more reliable system.
+* No: Unclear impact or lacking metrics to measure.
 
-Fixes that are known to be needed for a particular release should be pushed for review on that
-release's stable branch. They will then be included into the master branch when the stable branch
-is merged back.
+=== Complexity
+Q: `Can we support/maintain this feature once it is in Gerrit?`
 
-=== Finding starter projects to work on
+* Yes: Code will fit into codebase, be well tested, easy to
+  understand.
+* No: Will add code or a workflow that is hard to understand
+  and easy to misinterpret.
 
-We have created a
-link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
-category in the issue tracker and try to assign easy hack projects to it. If in
-doubt, do not hesitate to ask on the developer
-link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+Q: `Is the proposed feature or rework adding unnecessary complexity?`
 
-=== Upgrading Libraries
+* Yes: Adding a dependency on a well-supported library.
+* No: Adding a dependency on a library that is not widely used
+  or not actively maintained.
 
-Gerrit's library dependencies should only be upgraded if the new version contains
-something we need in Gerrit. This includes new features, API changes as well as bug
-or security fixes.
-An exception to this rule is that right after a new Gerrit release was branched
-off, all libraries should be upgraded to the latest version to prevent Gerrit
-from falling behind. Doing those upgrades should conclude at the latest two
-months after the branch was cut. This should happen on the master branch to ensure
-that they are vetted long enough before they go into a release and we can be sure
-that the update doesn't introduce a regression.
+=== Core vs. Plugin decision
+Q: `Would this fit better in a plugin?`
 
-[[deprecating-features]]
-=== Deprecating features
+* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+  is an index implementation) of a generic concept that others
+  might want to implement differently.
+* Yes: The proposed feature or rework is very specific to a custom setup.
+* No: The proposed feature or rework is applicable to a wider user
+  base.
+* No: The proposed feature or rework is a `core code review feature`.
 
-Gerrit should be as stable as possible and we aim to add only features that last.
-However, sometimes we are required to deprecate and remove features to be able
-to move forward with the project and keep the code-base clean. The following process
-should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
-is that we have a structured process for deprecation that users, administrators and
-developers can agree and rely on.
+=== Commitment
+Q: `Is someone willing to implement it?` (this question is
+especially important when reviewers propose alternative designs
+to the author’s own solution).
 
-General process:
+* Yes: The author or someone else commits to implementing the
+  proposed solution.
+* Yes: If a mentorship is required, a mentor is willing to help.
+* No: Unclear ownership, mentorship or implementation plan.
 
-  * Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
-    further development or improvement. If in doubt, consult the mailing list.
-  * If you can provide a schema migration that moves users to a comparable feature, do
-    so and stop here.
-  * Mark the feature as deprecated in the documentation and release notes.
-  * If possible, mark the feature deprecated in any user-visible interface. For example,
-    if you are deprecating a Git push option, add a message to the Git response if
-    the user provided the option informing them about deprecation.
-  * Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
-    Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
-    number that has to be branched off before removing the feature)
-  * Gate the feature behind a config that is off by default (forcing admins to turn
-    the deprecated feature on explicitly).
-  * After the next release was branched off, remove any code that backed the feature.
+=== Community
+Q: `If in doubt, is there a substantial benefit to a long-standing
+community member with many users?`
 
-You can optionally consult the mailing list to ask if there are users of the feature you
-wish to deprecate. If there are no major users, you can remove the feature without
-following this process and without the grace period of one release.
+* The community shapes the future of Gerrit as a product. In
+  cases of doubt, the ESC can be more generous with long-standing
+  community members compared to `drive-by` contributions.
 
 GERRIT
 ------
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
new file mode 100644
index 0000000..d101ffa
--- /dev/null
+++ b/Documentation/dev-crafting-changes.txt
@@ -0,0 +1,266 @@
+= Gerrit Code Review - Crafting Changes
+
+Here are some hints as to what approvers may be looking for
+before approving or submitting changes to the Gerrit project.
+Let's start with the simple nit picky stuff.  You are likely
+excited that your code works; help us share your excitement
+by not distracting us with the simple stuff.  Thanks to Gerrit,
+problems are often highlighted and we find it hard to look
+beyond simple spacing issues.  Blame it on our short attention
+spans, we really do want your code.
+
+[[commit-message]]
+== Commit Message
+
+It is essential to have a good commit message if you want your
+change to be reviewed.
+
+  * Keep lines no longer than 72 chars
+  * Start with a short one line summary
+  * Followed by a blank line
+  * Followed by one or more explanatory paragraphs
+  * Use the present tense (fix instead of fixed)
+  * Use the past tense when describing the status before this commit
+  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
+    `Feature: Issue <#>` line if implementing a feature request.
+  * Include a `Change-Id` line
+
+[[vim-setup]]
+=== Setting up Vim for Git commit message
+
+Git uses Vim as the default commit message editor. Put this into your
+`$HOME/.vimrc` file to configure Vim for Git commit message formatting
+and writing:
+
+====
+  " Enable spell checking, which is not on by default for commit messages.
+  au FileType gitcommit setlocal spell
+
+  " Reset textwidth if you've previously overridden it.
+  au FileType gitcommit setlocal textwidth=72
+====
+
+
+[[git-commit-settings]]
+=== A sample good Gerrit commit message:
+====
+  Add sample commit message to guidelines doc
+
+  The original patch set for the contributing guidelines doc did not
+  include a sample commit message, this new patchset does.  Hopefully this
+  makes things a bit clearer since examples can sometimes help when
+  explanations don't.
+
+  Note that the body of this commit message can be several paragraphs, and
+  that I word wrap it at 72 characters.  Also note that I keep the summary
+  line under 50 characters since it is often truncated by tools which
+  display just the git summary.
+
+  Bug: Issue 98765605
+  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
+====
+
+The `Change-Id` line is, as usual, created by a local git hook.  To install it,
+simply copy it from the checkout and make it executable:
+
+====
+  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
+  chmod +x .git/hooks/commit-msg
+====
+
+If you are working on core plugins, you will also need to install the
+same hook in the submodules:
+
+====
+  export hook=$(pwd)/.git/hooks/commit-msg
+  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
+====
+
+
+To set up git's remote for easy pushing, run the following:
+
+====
+  git remote add gerrit https://gerrit.googlesource.com/gerrit
+====
+
+The HTTPS access requires proper username and password; this can be obtained
+by clicking the 'Obtain Password' link on the
+link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
+Password tab of the user settings page].
+
+Alternately, you may use the
+link:https://pypi.org/project/git-review/[git-review] tool to submit changes
+to Gerrit. If you do, it will set up the Change-Id hook and `gerrit` remote
+for you. You will still need to do the HTTP access step.
+
+[[style]]
+== Style
+
+This project has a policy of Eclipse's warning free code. Eclipse
+configuration is added to git and we expect the changes to be
+warnings free.
+
+We do not ask you to use Eclipse for editing, obviously.  We do ask you
+to provide Eclipse's warning free patches only. If for some reasons, you
+are not able to set up Eclipse and verify, that your patch hasn't
+introduced any new Eclipse warnings, mention this in a comment to your
+change, so that reviewers will do it for you. Yes, the way to go is to
+extend gerrit CI to take care of this, but it's not yet implemented.
+
+Gerrit follows the
+link:https://google.github.io/styleguide/javaguide.html[Google Java Style
+Guide].
+
+To format Java source code, Gerrit uses the
+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 3.0.0). Unused dependencies are found and removed using the
+link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`]
+build tool, a sibling of `buildifier`.
+
+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.
+
+You may download and run `google-java-format` on your own, or you may
+run `./tools/setup_gjf.sh` to download a local copy and set up a
+wrapper script. If you run your own copy, please use the same version,
+as there may be slight differences between versions.
+
+When to use `final` modifier and when not (in new code):
+
+Always:
+
+  * final fields: marking fields as final forces them to be
+  initialized in the constructor or at declaration
+  * final static fields: clearly communicates the intent
+  * to use final variables in inner anonymous classes
+
+Optional:
+
+  * final classes: use when appropriate, e.g. API restriction
+  * final methods: similar to final classes
+
+Never:
+
+  * local variables: it clutters the code, and makes the code less
+  readable. When copying old code to new location, finals should
+  be removed
+  * method parameters: similar to local variables
+
+[[code-organization]]
+== Code Organization
+
+Do your best to organize classes and methods in a logical way.
+Here are some guidelines that Gerrit uses:
+
+  * Ensure a standard copyright header is included at the top
+    of any new files (copy it from another file, update the year).
+  * Always place loggers first in your class!
+  * Define any static interfaces next in your class.
+  * Define non static interfaces after static interfaces in your
+    class.
+  * Next you should define static types, static members, and
+    static methods, in decreasing order of visibility (public to private).
+  * Finally instance types, instance members, then constructors,
+    and then instance methods.
+  * Some common exceptions are private helper static methods, which
+    might appear near the instance methods which they help (but may
+    also appear at the top).
+  * Getters and setters for the same instance field should usually
+    be near each other barring a good reason not to.
+  * If you are using assisted injection, the factory for your class
+    should be before the instance members.
+  * Annotations should go before language keywords (`final`, `private`, etc) +
+    Example: `@Assisted @Nullable final type varName`
+  * Prefer to open multiple AutoCloseable resources in the same
+    try-with-resources block instead of nesting the try-with-resources
+    blocks and increasing the indentation level more than necessary.
+
+Wow that's a lot!  But don't worry, you'll get the habit and most
+of the code is organized this way already; so if you pay attention
+to the class you are editing you will likely pick up on it.
+Naturally new classes are a little harder; you may want to come
+back and consult this section when creating them.
+
+[[design]]
+== Design
+
+Here are some design level objectives that you should keep in mind
+when coding:
+
+  * Most client pages should perform only one RPC to load so as to
+    keep latencies down.  Exceptions would apply to RPCs which need
+    to load large data sets if splitting them out will help the
+    page load faster.  Generally page loads are expected to complete
+    in under 100ms.  This will be the case for most operations,
+    unless the data being fetched is not using Gerrit's caching
+    infrastructure.  In these slower cases, it is worth considering
+    mitigating this longer load by using a second RPC to fill in
+    this data after the page is displayed (or alternatively it might
+    be worth proposing caching this data).
+  * `@Inject` should be used on constructors, not on fields.  The
+    current exceptions are the ssh commands, these were implemented
+    earlier in Gerrit's development.  To stay consistent, new ssh
+    commands should follow this older pattern; but eventually these
+    should get converted to eliminate this exception.
+  * Don't leave repository objects (git or schema) open.  Use a
+    try-with-resources statement to ensure that repository objects get
+    closed after use.
+  * Don't leave UI components, which can cause new actions to occur,
+    enabled during RPCs which update Git repositories, including NoteDb.
+    This is to prevent people from submitting actions more than once
+    when operating on slow links.  If the action buttons are disabled,
+    they cannot be resubmitted and the user can see that Gerrit is still
+    busy.
+
+[[tests]]
+== Tests
+
+  * Tests for new code will greatly help your change get approved.
+
+[[change-size]]
+== Change Size/Number of Files Touched
+
+And finally, I probably cannot say enough about change sizes.
+Generally, smaller is better, hopefully within reason.  Do try to
+keep things which will be confusing on their own together,
+especially if changing one without the other will break something!
+
+  * If a new feature is implemented and it is a larger one, try to
+    identify if it can be split into smaller logical features; when
+    in doubt, err on the smaller side.
+  * Separate bug fixes from feature improvements.  The bug fix may
+    be an easy candidate for approval and should not need to wait
+    for new features to be approved.  Also, combining the two makes
+    reviewing harder since then there is no clear line between the
+    fix and the feature.
+  * Separate supporting refactoring from feature changes.  If your
+    new feature requires some refactoring, it helps to make the
+    refactoring a separate change which your feature change
+    depends on.  This way, reviewers can easily review the refactor
+    change as a something that should not alter the current
+    functionality, and feel more confident they can more easily
+    spot errors this way.  Of course, it also makes it easier to
+    test and locate later on if an unfortunate error does slip in.
+    Lastly, by not having to see refactoring changes at the same
+    time, it helps reviewers understand how your feature changes
+    the current functionality.
+  * Separate logical features into separate changes.  This
+    is often the hardest part.  Here is an example:  when adding a
+    new ability, make separate changes for the UI and the ssh
+    commands if possible.
+  * Do only what the commit message describes.  In other words, things which
+    are not strictly related to the commit message shouldn't be part of
+    a change, even trivial things like externalizing a string somewhere
+    or fixing a typo.  This helps keep `git blame` more useful in the future
+    and it also makes `git revert` more useful.
+  * Use topics to link your separate changes together.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design-doc-conclusion-template.md b/Documentation/dev-design-doc-conclusion-template.md
new file mode 100644
index 0000000..0625f2b
--- /dev/null
+++ b/Documentation/dev-design-doc-conclusion-template.md
@@ -0,0 +1,18 @@
+---
+title: "Design Doc - ${title} - Conclusion"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-conclusion.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
+# Conclusion
+
+Describe which decision was made and what were the reasons for it.
+
+## <a id="implementation-plan"> Implementation Plan
+
+If known, say who is driving the implementation, for when the
+implementation is planned and which priority it has.
diff --git a/Documentation/dev-design-doc-index-template.md b/Documentation/dev-design-doc-index-template.md
new file mode 100644
index 0000000..10b4a81
--- /dev/null
+++ b/Documentation/dev-design-doc-index-template.md
@@ -0,0 +1,18 @@
+---
+title: "Design Doc - ${title}"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
+# Design Doc - ${title}
+
+* [Use Cases](use-cases.html)
+* [Solution - ${solution-name-1}](solution-1.html)
+* [Solution - ${solution-name-2}](solution-2.html)
+* ...
+* [Conclusion](conclusion.html)
+
diff --git a/Documentation/dev-design-doc-solution-template.md b/Documentation/dev-design-doc-solution-template.md
new file mode 100644
index 0000000..8b2a8c0
--- /dev/null
+++ b/Documentation/dev-design-doc-solution-template.md
@@ -0,0 +1,72 @@
+---
+title: "Design Doc - ${title} - Solution - ${solution-name}"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-solution-${solution-name}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
+# Solution - ${solution-name}
+
+## <a id="overview"> Overview
+
+High-level overview; put details in the next section and background in
+the 'Background' section (see dev-design-doc-use-cases-template.txt).
+
+Should be understandable by engineers that are not working on Gerrit.
+
+If a solution is a variant of another solution, that other solution
+should be linked here.
+
+## <a id="detailed-design"> Detailed Design
+
+How does the overall design work? Details about the algorithms,
+storage format, APIs, etc., should be included here.
+
+For the initial review, it is ok for this to lack implementation
+details of minor importance.
+
+### <a id="scalability"> Scalability
+
+How does the solution scale?
+
+If applicable, consider:
+
+* data size increase
+* traffic increase
+* effects on replication across sites (master-replica and master-master)
+
+## <a id="alternatives-considered"> Alternatives Considered
+
+Within the scope of this solution you may need to describe what you did
+not do or why simpler approaches don't work. Mention other things to
+watch out for (if any).
+
+Do not describe alternative solutions in this section, as each solution
+should be described in a separate file.
+
+## <a id="pros-and-cons"> Pros and Cons
+
+Objectively list all points that speak in favor/against this solution.
+
+## <a id="implementation-plan"> Implementation Plan
+
+If known, say who would be willing to drive the implementation.
+
+It is possible to contribute solutions without having resources to do
+the implementation. In this case, say so here.
+
+If mentor support is desired, say so here. Also briefly describe any
+circumstances that can help with finding a suitable mentor.
+
+## <a id="time-estimation"> Time Estimation
+
+A rough itemized estimation of how much time it takes to implement this
+feature. Break down the feature into work items and estimate each item
+separately.
+
+If a mentor is assigned, this section must define a maximum time frame
+after which the mentorship automatically ends even if the feature isn't
+fully done yet.
diff --git a/Documentation/dev-design-doc-use-cases-template.md b/Documentation/dev-design-doc-use-cases-template.md
new file mode 100644
index 0000000..02c2fb5
--- /dev/null
+++ b/Documentation/dev-design-doc-use-cases-template.md
@@ -0,0 +1,48 @@
+---
+title: "Design Doc - ${title} - Use Cases"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-use-cases.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
+# Use Cases
+
+In a few sentences, describe the use-cases as interactions between a
+user and a system to attain particular goals.
+
+Should be understandable by anyone who is familiar with using Gerrit.
+
+Optionally, differentiate between primary and secondary use-cases.
+Secondary use-cases are related to the primary use-cases, but
+addressing them within the scope of this design is not mandatory. This
+means they may not be covered by all proposed solutions. Secondary
+use-cases that are not addressed by the concluded solution, may be
+discussed in separate design docs. In this case links to these design
+docs should be added here.
+
+Optionally, define non-goals.
+
+It is possible that use-cases are specific to custom setups (e.g. the
+multi-master setup at Google). In this case, say so here.
+
+## <a id="acceptance-criteria"> Acceptance Criteria
+
+Describe conditions that must be satisfied to consider the feature as
+done.
+
+If a mentor is assigned, the mentorship ends when this state is reached.
+Please note that a mentorship can also end earlier if the maximum time
+frame for the mentorship has exceeded (see section 'Time Estimation'
+in dev-design-doc-conclusion-template.txt).
+
+## <a id="background"> Background
+
+Stuff one needs to know to understand the use-cases (e.g. motivating
+examples, previous versions and problems, links to related
+changes/design docs, etc.).
+
+Note: this is background; do not write about your design or ideas to
+solve problems here.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
new file mode 100644
index 0000000..be80c94
--- /dev/null
+++ b/Documentation/dev-design-docs.txt
@@ -0,0 +1,144 @@
+= Gerrit Code Review - Design Docs
+
+For the link:dev-contributing.html#design-driven-contribution-process[
+design-driven contribution process] it is required to specify features
+upfront in a design doc.
+
+[[structure]]
+== Design Doc Structure
+
+A design doc should discuss the following aspects:
+
+* Use-Cases:
+  The interactions between a user and a system to attain particular
+  goals.
+* Acceptance Criteria
+  Conditions that must be satisfied to consider the feature as done.
+* Background:
+  Stuff one needs to know to understand the use-cases (e.g. motivating
+  examples, previous versions and problems, links to related
+  changes/design docs, etc.)
+* Possible Solutions:
+  Possible solutions with the pros and cons, and explanation of
+  implementation details.
+* Conclusion:
+  Which decision was made and what were the reasons for it.
+
+[[collaboration]]
+As community we want to collaborate on design docs as much as possible
+and write them together, in an iterative manner. To make this work well
+design docs are split into multiple files that can be written and
+refined by several persons in parallel:
+
+* `index.md`:
+  Entry file that links to the files below (also see
+  'dev-design-doc-index-template.md').
+* `use-cases.md`:
+  Describes the use-cases, acceptance criteria and background (also see
+  'dev-design-doc-use-cases-template.md').
+* `solution-<n>.md`:
+  Each possible solution (with the pros and cons, and implementation
+  details) is described in a separate file (also see
+  'dev-design-doc-solution-template.md').
+* `conclusion.md`:
+  Describes the conclusion of the design discussion (also see
+  'dev-design-doc-conclusion-template.md').
+
+[[expectation]]
+It is expected that:
+
+* An agreement on the use-cases is achieved before solutions are being
+  discussed in detail.
+* Anyone who has ideas for an alternative solution uploads a change
+  with a `solution-<n>.md` that describes their solution. In case of
+  doubt whether an idea is a refinement of an existing solution or an
+  alternative solution, it's up to the owner of the discussed solution
+  to decide if the solution should be updated, or if the proposer
+  should start a new alternative solution.
+* All possible solutions are fairly discussed with their pros and cons,
+  and treated equally until a conclusion is made.
+* Unrelated issues (judged by the design doc owner) that are identified
+  during discussions may be extracted into new design docs (initially
+  consisting only of an `index.md` and a `use-cases.md` file). Doing so
+  is optional yet can be done by either the design owner or reviewers.
+* Changes making iterative improvements can be submitted frequently
+  (e.g. additional uses-cases can be added later, solutions can be
+  submitted without describing implementation details, etc.).
+* After a conclusion has been approved contributors are expected to
+  keep the design doc updated and fill in gaps while they go forward
+  with the implementation.
+
+[[propose]]
+== How to propose a new design?
+
+To propose a new design, upload a change to the
+link:https://gerrit-review.googlesource.com/admin/repos/homepage[
+homepage] repository that adds a new folder under `pages/design-docs/`
+which contains at least an `index.md` and a `uses-cases.md` file (see
+link:#structure[design doc structure] above).
+
+Pushing a design doc for review requires to be a
+link:dev-roles.html#contributor[contributor].
+
+When contributing design docs, contributors should make clear whether
+they are committed to do the implementation. It is possible to
+contribute designs without having resources to do the implementation,
+but in this case the implementation is only done if someone volunteers
+to do it (which is not guaranteed to happen).
+
+Only very few maintainers actively watch out for uploaded design docs.
+To raise awareness you may want to send a notification to the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list about your uploaded design doc. But the discussion should
+not take place on the mailing list, comments should be made by reviewing
+the change in Gerrit.
+
+[[review]]
+== Design doc review
+
+Everyone in the link:dev-roles.html[Gerrit community] is welcome to
+take part in the design review and comment on the design. As such, every
+design reviewer is expected to respect the community
+link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct].
+
+Ideas for alternative solutions should be uploaded as a change that
+describes the solution (see link:#collaboration[above]). This should be
+done as early as possible during the review process, so that related
+comment threads stop there and do not clutter the current review. It is up
+to the alternative reviews to then host their related comments.
+
+Verification should be based on the generated `jekyll` site using the
+local `docker`, rather than via the rendering in `gitiles` (via
+`gerrit-review`).
+
+Changes which make a conclusion on a design (changes that add/change
+the `conclusion.md` file, see link:#structure[Design Doc Structure])
+should stay open for a minimum of 10 calendar days so that everyone has
+a fair chance to see them. It is important that concerns regarding a
+feature are raised during this time frame since once a conclusion is
+approved and submitted the implementation may start immediately.
+
+Other design doc changes can and should be submitted quickly so that
+collaboration and iterative refinements work smoothly (see
+link:#collaboration[above]).
+
+For proposed features the contributor should hear back from the
+link:dev-processes.html#steering-committee[engineering steering
+committee] within 14 calendar days whether the proposed feature is in
+scope of the project and if it can be accepted.
+
+[[watch-designs]]
+== How to get notified for new design docs?
+
+. Go to the
+  link:https://gerrit-review.googlesource.com/settings/#Notifications[
+  notification settings]
+. Add a project watch for the `homepage` repository with the following
+  query: `dir:pages/design-docs`
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 69af18d..fd53cac 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -178,17 +178,6 @@
 repositories for each project.
 
 
-== Project Information
-
-Gerrit is developed as a self-hosting open source project:
-
-* link:https://www.gerritcodereview.com/[Project Homepage]
-* link:https://www.gerritcodereview.com/download/index.html[Release Versions]
-* link:https://gerrit.googlesource.com/gerrit[Source]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
-* link:https://review.source.android.com/[Change Review]
-
-
 == Internationalization and Localization
 
 As a source code review system for open source projects, where the
@@ -204,8 +193,6 @@
 RTL into consideration, while others probably need to be modified
 before translating the UI to an RTL language.
 
-* link:i18n-readme.html[Gerrit's i18n Support]
-
 
 == Accessibility Considerations
 
@@ -533,7 +520,7 @@
 
 Because of the distributed nature of Git, end-users don't need to
 contact the central Gerrit Code Review server very often. For `git
-fetch` traffic, link:pgm-daemon.html[slave mode] is known to be an
+fetch` traffic, link:pgm-daemon.html[replica mode] is known to be an
 effective way to offload traffic from the main server, permitting it
 to scale to a large user base without needing an excessive number of
 cores in a single system.
@@ -640,29 +627,6 @@
 scope of Gerrit.
 
 
-== Testing Plan
-
-Gerrit is currently manually tested through its web UI.
-
-JGit has a fairly extensive automated unit test suite.  Most new
-changes to JGit are rejected unless corresponding automated unit
-tests are included.
-
-
-== Caveats
-
-Rietveld can't be used as it does not provide the "submit over the
-web" feature that Gerrit provides for Git.
-
-Gitosis can't be used as it does not provide any code review
-features, but it does provide basic access controls.
-
-Email based code review does not scale to a project as large and
-complex as Android.  Most contributors at least need some sort of
-dashboard to keep track of any pending reviews, and some way to
-correlate updated revisions back to the comments written on prior
-revisions of the same logical change.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index d038700..4364492 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -55,11 +55,6 @@
 * Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
 * Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
 * Check that compiler compliance level in gerrit project is set to: 9
-* Add this parameter to VM argument for gerrit_daemin launcher:
-----
-  --add-modules java.activation \
-  --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
-----
 
 [[Formatting]]
 == Code Formatter Settings
@@ -67,7 +62,7 @@
 To format source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
 tool (version 1.7), which automatically formats code to follow the
-style guide. See link:dev-contributing.html#style[Code Style] for the
+style guide. See link:dev-crafting-changes.html#style[Code Style] for the
 instruction how to set up command line tool that uses this formatter.
 The Eclipse plugin is provided that allows to format with the same
 formatter from within the Eclipse IDE. See
diff --git a/Documentation/dev-inspector.txt b/Documentation/dev-inspector.txt
index b1559ca..39736d7 100644
--- a/Documentation/dev-inspector.txt
+++ b/Documentation/dev-inspector.txt
@@ -11,7 +11,7 @@
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
   [--console-log]
-  [--slave]
+  [--replica]
   -s
 --
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 5077079..81790db 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -104,7 +104,7 @@
 *Code -> Reformat Code*, keyboard shortcuts, or the commit dialog will use the
 custom style defined by the `google-java-format` plugin.
 
-Please refer to the documentation on the <<dev-contributing#style,code style>>
+Please refer to the documentation on the <<dev-crafting-changes#style,code style>>
 for which version of `google-java-format` is used with Gerrit.
 
 ==== Code style settings
@@ -159,7 +159,7 @@
 plugin in IntelliJ IDEA.
 
 To simplify the creation of commit messages which are compliant with the
-<<dev-contributing#commit-message,Commit Message>> format, do the following:
+<<dev-crafting-changes#commit-message,Commit Message>> format, do the following:
 
 . Go to *File -> Settings -> Version Control -> Commit Dialog*.
 . In the *Commit message inspections*, activate the three inspections:
@@ -171,7 +171,7 @@
 right margin*.
 
 In addition, you should follow the instructions of
-<<dev-contributing#git_commit_settings,this section>> (if you haven't
+<<dev-crafting-changes#git-commit-settings,this section>> (if you haven't
 done so already):
 
 * Install the Git commit message hook for the `Change-Id` line.
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
new file mode 100644
index 0000000..b552472
--- /dev/null
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -0,0 +1,254 @@
+= Plugin Lifecycle
+
+Most of the plugins are hosted on the same instance as the
+link:https://gerrit-review.googlesource.com[Gerrit project itself] to make them
+more discoverable and have more chances to be reviewed by the whole community.
+
+[[hosting_lifecycle]]
+== Hosting Lifecycle
+
+The process of writing a new plugin goes through different phases:
+
+- Ideation and Discussion:
++
+The idea of creating a new plugin is posted and discussed on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
++
+Also see section link#ideation_discussion[Ideation and discussion] below.
+
+- Prototyping (optional):
++
+The author of the plugin creates a working prototype on a public repository
+accessible to the community.
++
+Also see section link#plugin_prototyping[Plugin Prototyping] below.
+
+- Proposal and Hosting:
++
+The author proposes to release the plugin under the
+link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 OpenSource
+license] and requests the plugin to be hosted on
+link:https://gerrit-review.googlesource.com[the Gerrit project site]. The
+proposal must be   accepted by at least one Gerrit maintainer. In case of
+disagreement between maintainers, the issue can be escalated to the
+link:dev-processes.html#steering-committee[Engineering Steering Committee]. If
+the plugin is accepted, the Gerrit maintainer creates the project under the
+plugins path on link:https://gerrit-review.googlesource.com[the Gerrit project
+site].
++
+Also see section link#plugin_proposal[Plugin Proposal] below.
+
+- Build:
++
+To make the consumption of the plugin easy and to notice plugin breakages early
+the plugin author should setup build jobs on
+link:https://gerrit-ci.gerritforge.com[the GerritForge CI] that build the
+plugin for each Gerrit version that it supports.
++
+Also see section link#build[Build] below.
+
+- Development and Contribution:
++
+The author develops a production-ready code base of the plugin, with
+contributions, reviews, and help from the Gerrit community.
++
+Also see section link#development_contribution[Development and contribution]
+below.
+
+- Release:
++
+The author releases the plugin by creating a Git tag and announcing the plugin
+on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list.
++
+Also see section link#plugin_release[Plugin release] below.
+
+- Maintenance:
++
+The author maintains their plugins as new Gerrit versions are released, updates
+them when necessary, develops further existing or new features and reviews
+incoming contributions.
+
+- Deprecation:
++
+The author declares that the plugin is not maintained anymore or is deprecated
+and should not be used anymore.
++
+Also see section link#plugin_deprecation[Plugin deprecation] below.
+
+[[ideation_discussion]]
+== Ideation and Discussion
+
+Starting a new plugin project is a community effort: it starts with the
+identification of a gap in the Gerrit Code Review product but evolves with the
+contribution of ideas and suggestions by the whole community.
+
+The ideator of the plugin starts with an RFC (Request For Comments) post on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list
+with a description of the main reasons for starting a new plugin.
+
+Example of a post:
+
+----
+  [RFC] Code-Formatter plugin
+
+  Hello, community,
+  I am proposing to create a new plugin for Gerrit called 'Code-Formatter', see
+  the details below.
+
+  *The gap*
+  Often, when I post a new change to Gerrit, I forget to run the common code
+  formatting tool (e.g. Google-Java-Format for the Gerrit project). I would
+  like Gerrit to be in charge of highlighting these issues to me and save many
+  people's time.
+
+  *The proposal*
+  The Code-Formatter plugin reads the formatting rules in the project config
+  and applies them automatically to every patch-set. Any issue is reported as a
+  regular review comment to the patchset, highlighting the part of the code to
+  be changed.
+
+  What do you think? Did anyone have the same idea or need?
+----
+
+The idea is discussed on the mailing list and can evolve based on the needs and
+inputs from the entire community.
+
+After the discussion, the ideator of the plugin can decide to start prototyping
+on it or park the proposal, if the feedback provided an alternative solution to
+the problem. The prototype phase can be optionally skipped if the idea is clear
+enough and receives a general agreement from the Gerrit maintainers. The author
+can be given a "leap of faith" and can go directly to the format plugin
+proposal (see below) and the creation of the plugin repository.
+
+[[plugin_prototyping]]
+== Plugin Prototyping
+
+The initial idea is translated to code by the plugin author. The development
+can happen on any public or private source code repository and can involve one
+or more contributors. The purpose of prototyping is to verify that the idea can
+be implemented and provides the expected benefits.
+
+Once a working prototype is ready, it can be announced as a follow-up to the
+initial RFC proposal so that other members of the community can see the code
+and try the plugin themselves.
+
+[[plugin_proposal]]
+== Plugin Proposal
+
+The author decides that the plugin prototype makes sense as a general purpose
+plugin and decides to release the code with the same
+link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license]
+as the Gerrit Code Review project and have it hosted on
+link:https://gerrit-review.googlesource.com[the Gerrit project site].
+
+The plugin author formalizes the proposal with a follow-up of the initial RFC
+post and asks for public opinion on it.
+
+Example:
+
+----
+  Re - [RFC] Code-Formatter plugin
+
+  Hello, community,
+  thanks for your feedback on the prototype. I have now decided to donate the
+  project to the Gerrit Code Review project and make it a plugin:
+
+  Plugin name:
+  /plugins/code-formatter
+
+  Plugin description:
+    Plugin to allow automatic posting review based on code-formatting rules
+----
+
+The community discusses the proposal and the value of the plugin for the whole
+project; the result of the discussion can end up in one of the following cases:
+
+- The plugin's project request is widely appreciated and formally accepted by
+  at least one Gerrit maintainer who creates the repository as child project of
+  'Public-Projects' on link:https://gerrit-review.googlesource.com[the Gerrit
+  project site], creates an associated plugin owners group with "Owner"
+  permissions for the plugin and adds the plugin's author as member of it.
+- The plugin's project is widely appreciated; however, another existing plugin
+  already partially covers the same use-case and thus it would make more sense
+  to have the features integrated into the existing plugin. The new plugin's
+  author contributes his prototype commits refactored to be included as change
+  into the existing plugin.
+- The plugin's project is found useful; however, it is too specific to the
+  author's use-case and would not make sense outside of it. The plugin remains
+  in a public repository, widely accessible and OpenSource, but not hosted on
+  link:https://gerrit-review.googlesource.com[the Gerrit project site].
+
+[[build]]
+== Build
+
+The plugin's maintainer creates a job on the
+link:https://gerrit-ci.gerritforge.com[GerritForge CI] by creating a new YAML
+definition in the link:https://gerrit.googlesource.com/gerrit-ci-scripts[Gerrit
+CI Scripts] repository.
+
+Example of a YAML CI job for plugins:
+
+----
+  - project:
+    name: code-formatter
+    jobs:
+      - 'plugin-{name}-bazel-{branch}':
+          branch:
+            - master
+----
+
+[[development_contribution]]
+== Development and Contribution
+
+The plugin follows the same lifecycle as Gerrit Code Review and needs to be
+kept up-to-date with the current active branches, according to the
+link:https://www.gerritcodereview.com/#support[current support policy].
+During the development, the plugin's maintainer can reward contributors
+requesting to be more involved and making them maintainers of his plugin,
+adding them to the list of the project owners.
+
+[[plugin_release]]
+== Plugin Release
+
+The plugin's maintainer is the only person responsible for making and
+announcing the official releases, typically, but not limited to, in conjunction
+with the major releases of Gerrit Code Review. The plugin's maintainer may tag
+his plugin and follow the notation and semantics of the Gerrit Code Review
+project; however it is not mandatory and many of the plugins do not have any
+tags or releases.
+
+Example of a YAML CI job for a plugin compatible with multiple Gerrit versions:
+
+----
+  - project:
+    name: code-formatter
+    jobs:
+      - 'plugin-{name}-bazel-{branch}-{gerrit-branch}':
+          branch:
+            - master
+          gerrit-branch:
+            - master
+            - stable-3.0
+            - stable-2.16
+----
+
+[[plugin_deprecation]]
+== Plugin Deprecation
+
+The plugin's maintainer and the community have agreed that the plugin is not
+useful anymore or there isn't anyone willing to contribute to bringing it
+forward and keeping it up-to-date with the recent versions of Gerrit Code
+Review.
+
+The plugin's maintainer puts a deprecation notice in the README.md of the
+plugin and pushes it for review. If nobody is willing to bring the code
+forward, the change gets merged, and the master branch is removed from the list
+of branches to be built on the GerritFoge CI.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8ad5535..799c13c 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -1,7 +1,8 @@
 = Gerrit Code Review - Plugin Development
 
 The Gerrit server functionality can be extended by installing plugins.
-This page describes how plugins for Gerrit can be developed.
+This page describes how plugins for Gerrit can be developed and hosted
+on gerrit-review.googlesource.com.
 
 For PolyGerrit-specific plugin development, consult with
 link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
@@ -963,6 +964,11 @@
 }
 ----
 
+Implementors of the `ChangeAttributeFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag
+computation] to prevent callers using ETags from potentially seeing outdated
+plugin attributes.
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
@@ -2300,7 +2306,8 @@
 volume efficiently.
 
 Gerrit implements this extension point, but plugins may bind another
-implementation, e.g. one that supports multi-master.
+implementation, e.g. one that supports cluster setup with multiple
+primary Gerrit nodes handling write operations.
 
 ----
 DynamicItem.bind(binder(), AccountPatchReviewStore.class)
@@ -2467,10 +2474,10 @@
 [source, java]
 ----
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 
 import java.util.Set;
 
@@ -2594,10 +2601,8 @@
 modifications, `NOT_READY`. Other statuses are available for particular cases.
 A change can be submitted if all the plugins accept the change.
 
-Plugins may also decide not to vote on a given change by returning an empty
-Collection (ie: the plugin is not enabled for this repository), or to vote
-several times (ie: one SubmitRecord per project in the hierarchy).
-The results are handled as if multiple plugins voted for the change.
+Plugins may also decide not to vote on a given change by returning an
+`Optional.empty()` (ie: the plugin is not enabled for this repository).
 
 If a plugin decides not to vote, it's name will not be displayed in the UI and
 it will not be recoded in the database.
@@ -2632,20 +2637,20 @@
 
 [source, java]
 ----
-import java.util.Collection;
+import java.util.Optional;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRecord.Status;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 
 public class MyPluginRules implements SubmitRule {
-  public Collection<SubmitRecord> evaluate(ChangeData changeData) {
+  public Optional<SubmitRecord> evaluate(ChangeData changeData) {
     // Implement your submitability logic here
 
     // Assuming we want to prevent this change from being submitted:
-    SubmitRecord record;
+    SubmitRecord record = new SubmitRecord();
     record.status = Status.NOT_READY;
-    return record;
+    return Optional.of(record);
   }
 }
 ----
@@ -2678,6 +2683,82 @@
 are met, but marked as `OK`. If the requirements were not displayed, reviewers
 would need to use their precious time to manually check that they were met.
 
+Implementors of the `SubmitRule` interface should check whether they need to
+contribute to the link:#change-etag-computation[change ETag computation] to
+prevent callers using ETags from potentially seeing outdated submittability
+information.
+
+[[change-etag-computation]]
+== Change ETag Computation
+
+By implementing the `com.google.gerrit.server.change.ChangeETagComputation`
+interface plugins can contribute a value to the change ETag computation.
+
+Plugins can affect the result of the get change / get change details REST
+endpoints by:
+
+* providing link:#query_attributes[plugin defined attributes] in
+  link:rest-api-changes.html#change-info[ChangeInfo]
+* implementing a link:#pre-submit-evaluator[pre-submit evaluator] which affects
+  the computation of `submittable` field in
+  link:rest-api-changes.html#change-info[ChangeInfo]
+
+If the plugin defined part of link:rest-api-changes.html#change-info[
+ChangeInfo] depends on plugin specific data, callers that use change ETags to
+avoid unneeded recomputations of ChangeInfos may see outdated plugin attributes
+and/or outdated submittable information, because a ChangeInfo is only reloaded
+if the change ETag changes.
+
+By implementating the `com.google.gerrit.server.change.ChangeETagComputation`
+interface plugins can contribute to the ETag computation and thus ensure that
+the change ETag changes when the plugin data was changed. This way it can be
+ensured that callers do not see outdated ChangeInfos.
+
+IMPORTANT: Change ETags are computed very frequently and the computation must
+be cheap. Take good care to not perform any expensive computations when
+implementing this.
+
+[source, java]
+----
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.hash.Hasher;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.change.ChangeETagComputation;
+
+public class MyPluginChangeETagComputation implements ChangeETagComputation {
+  public String getETag(Project.NameKey projectName, Change.Id changeId) {
+    Hasher hasher = Hashing.murmur3_128().newHasher();
+
+    // Add hashes for all plugin-specific data that affects change infos.
+    hasher.putString(sha1OfPluginSpecificChangeRef, UTF_8);
+
+    return hasher.hash().toString();
+  }
+}
+----
+
+[[exception-hook]]
+== ExceptionHook
+
+An `ExceptionHook` allows implementors to control how certain
+exceptions should be handled.
+
+This interface is intended to be implemented for multi-master setups to
+control the behavior for handling exceptions that are thrown by a lower
+layer that handles the consensus and synchronization between different
+server nodes. E.g. if an operation fails because consensus for a Git
+update could not be achieved (e.g. due to slow responding server nodes)
+this interface can be used to retry the request instead of failing it
+immediately.
+
+[[mail-soy-template-provider]]
+== MailSoyTemplateProvider
+
+This extension point allows to provide soy templates for registration
+so that they can be used for sending emails from a plugin.
+
 [[quota-enforcer]]
 == Quota Enforcer
 
@@ -2766,6 +2847,20 @@
 }
 ----
 
+[[performance-logger]]
+== Performance Logger
+
+`com.google.gerrit.server.logging.PerformanceLogger` is an extension point that
+is invoked for all operations for which the execution time is measured. The
+invocation of the extension point does not happen immediately, but only at the
+end of a request (REST call, SSH call, git push). Implementors can write the
+execution times into a performance log for further analysis.
+
+[[request-listener]]
+== Request Listener
+
+`com.google.gerrit.server.RequestListener` is an extension point that is
+invoked each time the server executes a request from a user.
 
 == SEE ALSO
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
new file mode 100644
index 0000000..f4e77a8
--- /dev/null
+++ b/Documentation/dev-processes.txt
@@ -0,0 +1,355 @@
+= Gerrit Code Review - Development Processes
+
+[[project-governance]]
+[[steering-committee]]
+== Project Governance / Engineering Steering Committee
+
+The Gerrit project has an engineering steering committee (ESC) that is
+in charge of:
+
+* Gerrit core (the `gerrit` project) and the core plugins
+* defining the project vision and the project scope
+* maintaining a roadmap, a release plan and a prioritized backlog
+* ensuring timely design reviews
+* ensuring that new features are compatible with the project vision and
+  are well aligned with other features (give feedback on new
+  link:dev-design-docs.html[design docs] within 14 calendar days)
+* approving/rejecting link:dev-design-docs.html[designs], vetoing new
+  features
+* assigning link:dev-roles.html#mentor[mentors] for approved features
+* accepting new plugins as core plugins
+* making changes to the project governance process and the
+  link:dev-contributing.html#contribution-processes[contribution
+  processes]
+
+The steering committee has 5 members:
+
+* 3 Googlers that are appointed by Google
+* 2 non-Google maintainers, elected by non-Google maintainers for the
+  period of 1 year (see link:#steering-committee-election[below])
+
+Refer to the project homepage for the link:https://www.gerritcodereview.com/members.html#engineering-steering-committee[
+list of current committee members].
+
+The steering committee should act in the interest of the Gerrit project
+and the whole Gerrit community.
+
+For decisions, consensus between steering committee members and all
+other maintainers is desired. If consensus cannot be reached, decisions
+can also be made by simple majority in the steering committee (should
+be applied only in exceptional situations).
+
+The steering committee is empowered to overrule positive/negative votes
+from individual maintainers, but should do so only in exceptional
+situations after attempts to reach consensus have failed.
+
+As an integral part of the Gerrit community, the steering committee is
+committed to transparency and to answering incoming requests in a
+timely manner.
+
+[[steering-committee-election]]
+=== Election of non-Google steering committee members
+
+The election of the non-Google steering committee members happens once
+a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+can nominate themselves by posting an informal application on the
+non-public maintainers mailing list by end of April (deadline for 2019
+is Mon 13th of May). By applying to be steering committee member, the
+candidate confirms to be able to dedicate the time that is needed to
+fulfill this role (also see
+link:dev-roles.html#steering-committee-member[steering committee
+member]).
+
+Each non-Google maintainer can vote for 2 candidates. The voting
+happens by posting on the maintainer mailing list. The voting period is
+14 calendar days from the nomination deadline (except for 2019, where
+the initial steering committee should be confirmed during the Munich
+hackathon, the voting period goes from 14th May to 16th May).
+
+Google maintainers do not take part in this vote, because Google
+already has dedicated seats in the steering committee (see section
+link:steering-committee[steering committee]).
+
+[[contribution-process]]
+== Contribution Process
+
+See link:dev-contributing.html[here].
+
+[[design-doc-review]]
+== Design Doc Review
+
+See link:dev-design-docs.html#review[here].
+
+[[versioning]]
+== Semantic versioning
+
+Gerrit follows a light link:https://semver.org/[semantic versioning scheme] MAJOR.MINOR[.PATCH[.HOTFIX]]
+format:
+
+  * MAJOR is incremented when there are substantial incompatible changes and/or
+    new features in Gerrit.
+  * MINOR is incremented when there are changes that are typically backward compatible
+    with the earlier minor version. Features can be removed following the
+    link:#deprecating-features[feature deprecation process]. Dependencies can be upgraded
+    according to the link:dev-processes.html#upgrading-libraries[libraries upgrade policy].
+  * PATCH is incremented when there are backward-compatible bug fixes in Gerrit or its
+    dependencies. When PATCH is zero, it can be omitted.
+  * HOTFIX is present only when immediately after a patch release, some urgent
+    fixes in the code or the packaging format are required but do not justify a
+    new patch release.
+
+For every MAJOR.MINOR release there is an associated stable branch that follows well defined
+link:#dev-in-stable-branches[rules of development].
+
+Within a stable branch, there are multiple MAJOR.MINOR.PATCH tags created associated to the
+bug-fix releases of that stable release.
+
+Examples:
+
+* Gerrit v3.0.0 contains breaking incompatible changes in the functionality because
+  the ReviewDb storage has been totally removed.
+* Gerrit v2.15 contains brand-new features like NoteDb, however, still supports the existing
+  ReviewDb storage for changes and thus is considered a minor release.
+* Gerrit v2.14.20 is the 20th patch-release of the stable Gerrit v2.14.* and thus does not contain
+  new features but only bug-fixes.
+
+[[dev-in-stable-branches]]
+== Development in stable branches
+
+As their name suggests stable branches are intended to be stable. This means that generally
+only bug-fixes should be done on stable branches, however this is not strictly enforced and
+exceptions may apply:
+
+  * When a stable branch is initially created to prepare a new release the Gerrit community
+    discusses on the mailing list if there are pending features which should still make it into the
+    release. Those features are blocking the release and should be implemented on the stable
+    branch before the first release candidate is created.
+  * To stabilize the code before doing a major release several release candidates are created. Once
+    the first release candidate was done no more features should be accepted on the stable branch.
+    If more features are found to be required they should be discussed with the steering committee
+    and should only be allowed if the risk of breaking things is considered to be low.
+  * Once a major release is done only bug-fixes and documentation updates should be done on the
+    stable branch. These updates will be included in the next minor release.
+  * For minor releases new features could be acceptable if the following conditions are met:
+    ** they are result of a new feature introduced through a merge of an earlier stable branch
+    ** they are justified for completing, extending or fixing an existing feature
+    ** does not involve API, user-interface changes or data migrations
+    ** is backward compatible with all existing features
+    ** the parts of the code in common with existing features are properly covered by end-to-end tests
+    ** is important to the Gerrit community and no Gerrit maintainers have raised objections.
+  * In cases of doubt or conflicting opinions on new features, it's the responsibility of the
+    steering committee to evaluate the risk of new features and make a decision based on these
+    rules and opinions from the Gerrit community.
+  * The older a stable branch is the more stable it should be. This means old stable branches
+    should only receive bug-fixes that are either important or low risk. Security fixes, including
+    security updates for third party dependencies, are always considered as important and hence can
+    always be done on stable branches.
+
+Examples:
+
+* Gerrit v3.0.0-rc1 and v3.0.0-rc2 may contain new features and API changes without notice,
+  even if they are both cut on the same stable-3.0 branch.
+* Gerrit v2.14.8 introduced the support for ElasticSearch as a new feature. This was an exception
+  agreed amongst the Gerrit maintainers, did not touch the Lucene indexing code-base, was supported
+  by container-based E2E tests and represents a completion of an high-level feature.
+
+[[backporting]]
+== Backporting to stable branches
+
+From time to time bug fix releases are made for existing stable branches.
+
+Developers concerned with stable branches are encouraged to backport or push fixes to these
+branches, even if no new release is planned. Backporting features is only possible in compliance
+with the rules link:#dev-in-stable-branches[above].
+
+Fixes that are known to be needed for a particular release should be pushed for review on that
+release's stable branch. They will then be included into the master branch when the stable branch
+is merged back.
+
+[[security-issues]]
+== Dealing with Security Issues
+
+If a security vulnerability in Gerrit is discovered, we place an link:#embargo[
+embargo] on it until a fixed release or mitigation is available. Fixing the
+issue is usually pursued with high priority (depends on the severity of the
+security vulnerability). The embargo is lifted and the vulnerability is
+disclosed to the community as soon as a fix release or another mitigation is
+available.
+
+[[report-security-issue]]
+=== How to report a security vulnerability?
+
+To report a security vulnerability file a
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Security+Issue[
+security issue] in the Gerrit issue tracker. The visibility of issues that are
+created with the `Security Issue` template is automatically restricted to
+Gerrit maintainers and a few long-term contributors. This means as a reporter
+you may not be able to see the issue once it is created. Security issues are
+created on the `ESC` component so that they will be discussed at the next
+meeting of the link:#steering-committee[Engineering Steering Committee] which
+takes place biweekly.
+
+If an existing issue is found to be a security vulnerability it should be
+turned into a security issue by:
+
+. Setting the component to `ESC`
+. Adding the labels `Security` and `NonPublic`
+
+In case of doubt, or if an issue cannot wait until the next ESC meeting,
+contact the link:#steering-committee[Engineering Steering Committee] directly
+by sending them an mailto:gerritcodereview-esc@googlegroups.com[email].
+
+If needed, the ESC will contact the reporter for additional details.
+
+[[embargo]]
+=== The Embargo
+
+Once an issue has been identified as security vulnerability, we keep it under
+embargo until a fixed release or a mitigation is available. This means that the
+issue is not discussed publicly, but only on issues with restricted visibility
+(see link:#report-security-issue[above]) and at the mailing lists of the ESC,
+community managers and Gerrit maintainers. Since the `repo-discuss` mailing
+list is public, security issues must not be discussed on this mailing list
+while the embargo is in place.
+
+The reason for keeping an embargo is to prevent attackers from taking advantage
+of a vulnerability while no fixed releases are available yet, and Gerrit
+administrators cannot make their systems secure.
+
+Once a fix release or mitigation is available, the embargo is lifted and the
+community is informed about the security vulnerability with the advise to
+address the security vulnerability immediately (either by upgrading to a fixed
+release or applying the mitigation). The information about the security
+vulnerability is disclosed via the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+
+[[handle-security-issue]]
+=== Handling of the Security Vulnerability
+
+. Engineering Steering Committee evaluates the security vulnerability:
++
+The ESC discusses the security vulnerability and which actions should be taken
+to address it. One person, usually one of the Gerrit maintainers, should be
+appointed to drive and coordinate the investigation and the fix of the security
+vulnerability. This coordinator doesn't need to do all the work alone, but is
+responsible that the security vulnerability is getting fixed in a timely
+manner.
++
+If the security vulnerability affects multiple or older releases the ESC should
+decide which of the releases should be fixed. For critical security issue we
+also consider fixing old releases that are otherwise not receiving any
+bug-fixes anymore.
++
+It's also possible that the ESC decides that an issue is not a security issue
+and the embargo is lifted immediately.
+
+. Implementation of the security fix:
++
+To keep the embargo intact, security fixes cannot be developed and reviewed in
+the public `gerrit` repository. In particular it's not secure to use private
+changes for implementing and reviewing security fixes (see general notes about
+link:intro-user.html[security-fixes]).
++
+Instead security fixes should be implemented and reviewed in the non-public
+link:https://gerrit-review.googlesource.com/admin/repos/gerrit-security-fixes[
+gerrit-security-fixes] repository which is only accessible by Gerrit
+maintainers and Gerrit community members that work on security fixes.
++
+The change that fixes the security vulnerability should contain an integration
+test that verifies that the security vulnerability is no longer present.
++
+Review and approval of the security fixes must be done by the Gerrit
+maintainers. Verifications must be done manually since the Gerrit CI doesn't
+build and test changes of the `gerrit-security-fixes` repository (and it
+shouldn't because everything on the CI server is public which would break
+the embargo).
++
+Once a security fix is ready and submitted, it should be cherry-picked to all
+branches that should be fixed.
+
+. Creation of fixed releases and announcement of the security vulnerability:
++
+A release manager should create new bug fix releases for all fixed branches.
++
+The new releases should be tested against the security vulnerability to
+double-check that the release was built from the correct source that contains
+the fix for the security vulnerability.
++
+Before publishing the fixed releases, an announcement to the Gerrit community
+should be prepared. The announcement should clearly describe the security
+vulnerability, which releases are affected and which releases contain the fix.
+The announcement should recommend to upgrade to fixed releases immediately.
++
+Once all releases are ready and tested and the announcement is prepared, the
+releases should be all published at the same time. Immediately after that, the
+announcement should be sent out to the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
++
+This ends the embargo and any issue that discusses the security vulnerability
+should be made public.
+
+. Follow-Up
++
+The ESC should discuss if there are any learnings from the security
+vulnerability and define action items to follow up in the
+link:https://bugs.chromium.org/p/gerrit[issue tracker].
+
+[[upgrading-libraries]]
+== Upgrading Libraries
+
+Changes that add new libraries or upgrade existing libraries require an approval on the
+`Library-Compliance` label. For an approval the following things are checked:
+
+* The library has a license that is suitable for use within Gerrit.
+* If the library is used within Google, the version of the library must be compatible with the
+  version that is used at Google.
+
+Only maintainers from Google can vote on the `Library-Compliance` label.
+
+Gerrit's library dependencies should only be upgraded if the new version contains
+something we need in Gerrit. This includes new features, API changes as well as bug
+or security fixes.
+An exception to this rule is that right after a new Gerrit release was branched
+off, all libraries should be upgraded to the latest version to prevent Gerrit
+from falling behind. Doing those upgrades should conclude at the latest two
+months after the branch was cut. This should happen on the master branch to ensure
+that they are vetted long enough before they go into a release and we can be sure
+that the update doesn't introduce a regression.
+
+[[deprecating-features]]
+== Deprecating features
+
+Gerrit should be as stable as possible and we aim to add only features that last.
+However, sometimes we are required to deprecate and remove features to be able
+to move forward with the project and keep the code-base clean. The following process
+should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
+is that we have a structured process for deprecation that users, administrators and
+developers can agree and rely on.
+
+General process:
+
+  * Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
+    further development or improvement. If in doubt, consult the mailing list.
+  * If you can provide a schema migration that moves users to a comparable feature, do
+    so and stop here.
+  * Mark the feature as deprecated in the documentation and release notes.
+  * If possible, mark the feature deprecated in any user-visible interface. For example,
+    if you are deprecating a Git push option, add a message to the Git response if
+    the user provided the option informing them about deprecation.
+  * Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
+    Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
+    number that has to be branched off before removing the feature)
+  * Gate the feature behind a config that is off by default (forcing admins to turn
+    the deprecated feature on explicitly).
+  * After the next release was branched off, remove any code that backed the feature.
+
+You can optionally consult the mailing list to ask if there are users of the feature you
+wish to deprecate. If there are no major users, you can remove the feature without
+following this process and without the grace period of one release.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index c014687..ad25147 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -3,7 +3,9 @@
 To build a developer instance, you'll need link:https://bazel.build/[Bazel] to
 compile the code, preferably launched with link:https://github.com/bazelbuild/bazelisk[Bazelisk].
 
-== Getting the Source
+== Git Setup
+
+=== Getting the Source
 
 Create a new client workspace:
 
@@ -12,102 +14,36 @@
   cd gerrit
 ----
 
-The `--recursive` option is needed on `git clone` to ensure that
-the core plugins, which are included as git submodules, are also
-cloned.
+The `--recurse-submodules` option is needed on `git clone` to ensure that the
+core plugins, which are included as git submodules, are also cloned.
+
+=== Switching between branches
+
+When using `git checkout` without `--recurse-submodules` to switch between
+branches, submodule revisions are not altered, which can result in:
+
+*  Incorrect or unneeded plugin revisions.
+*  Missing plugins.
+
+After you switch branches, ensure that you have the correct versions of
+the submodules.
+
+CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
+directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
+link:https://git-scm.com/docs/git-clean[git-clean].
+
+Run the following:
+
+----
+  git submodule update
+  git clean -ffd
+----
 
 [[compile_project]]
 == Compiling
 
 For details, see <<dev-bazel#,Building with Bazel>>.
 
-== Configuring Eclipse
-
-To use the Eclipse IDE for development, see
-link:dev-eclipse.html[Eclipse Setup].
-
-To configure the Eclipse workspace with Bazel, see
-link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
-
-== Configuring IntelliJ IDEA
-
-See <<dev-intellij#,IntelliJ Setup>> for details.
-
-== MacOS
-
-On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
-and that `JAVA_HOME` is set to the
-link:install.html#Requirements[required Java version].
-
-Java installations can typically be found in
-"/System/Library/Frameworks/JavaVM.framework/Versions".
-
-To check the installed version of Java, open a terminal window and run:
-
-`java -version`
-
-[[init]]
-== Site Initialization
-
-After you compile the project <<compile_project,(above)>>, run the Gerrit
-`init`
-command to create a test site:
-
-----
-  $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war init -d ../gerrit_testsite
-----
-
-[[special_bazel_java_version]]
-NOTE: You must use the same Java version that Bazel used for the build, which
-is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
-
-During initialization, change two settings from the defaults:
-
-*  To ensure the development instance is not externally accessible, change the
-listen addresses from '*' to 'localhost'.
-*  To allow yourself to create and act as arbitrary test accounts on your
-development instance, change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT'.
-
-After initializing the test site, Gerrit starts serving in the background. A
-web browser displays the Start page.
-
-On the Start page, you can:
-
-.  Log in as the account you created during the initialization process.
-.  Register additional accounts.
-.  Create projects.
-
-To shut down the daemon, run:
-
-----
-  ../gerrit_testsite/bin/gerrit.sh stop
-----
-
-
-[[localdev]]
-== Working with the Local Server
-
-To create more accounts on your development instance:
-
-.  Click 'become' in the upper right corner.
-.  Select 'Switch User'.
-.  Register a new account.
-.  link:user-upload.html#ssh[Configure your SSH key].
-
-Use the `ssh` protocol to clone from and push to the local server. For
-example, to clone a repository that you've created through the admin
-interface, run:
-
-----
-git clone ssh://username@localhost:29418/projectname
-----
-
-To create changes as users of Gerrit would, run:
-
-----
-git push origin HEAD:refs/for/master
-----
 
 == Testing
 
@@ -130,6 +66,92 @@
 <<dev-e2e-tests#,This document>> describes how `e2e` (load or functional) test
 scenarios are implemented using link:https://gatling.io/[`Gatling`].
 
+
+== Local server
+
+[[init]]
+=== Site Initialization
+
+After you compile the project <<compile_project,(above)>>, run the Gerrit
+`init`
+command to create a test site:
+
+----
+  export GERRIT_SITE=~/gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+      -jar bazel-bin/gerrit.war init --batch --dev -d $GERRIT_SITE
+----
+
+[[special_bazel_java_version]]
+NOTE: You must use the same Java version that Bazel used for the build, which
+is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
+
+This command takes two parameters:
+
+* `--batch` assigns default values to several Gerrit configuration
+    options. To learn more about these options, see
+    link:config-gerrit.html[Configuration].
+* `--dev` configures the Gerrit server to use the authentication
+  option, `DEVELOPMENT_BECOME_ANY_ACCOUNT`, which enables you to
+  switch between different users to explore how Gerrit works. To learn more
+  about setting up Gerrit for development, see
+  link:dev-readme.html[Gerrit Code Review: Developer Setup].
+
+After initializing the test site, Gerrit starts serving in the background. A
+web browser displays the Start page.
+
+On the Start page, you can:
+
+.  Log in as the account you created during the initialization process.
+.  Register additional accounts.
+.  Create projects.
+
+To shut down the daemon, run:
+
+----
+  $GERRIT_SITE/bin/gerrit.sh stop
+----
+
+
+[[localdev]]
+=== Working with the Local Server
+
+To create more accounts on your development instance:
+
+.  Click 'become' in the upper right corner.
+.  Select 'Switch User'.
+.  Register a new account.
+.  link:user-upload.html#ssh[Configure your SSH key].
+
+Use the `ssh` protocol to clone from and push to the local server. For
+example, to clone a repository that you've created through the admin
+interface, run:
+
+----
+git clone ssh://username@localhost:29418/projectname
+----
+
+To use the `HTTP` protocol, run:
+
+----
+git clone http://username@localhost:8080/projectname
+----
+
+The default password for user `admin` is `secret`. You can regenerate a
+password in the UI under User Settings -- HTTP credentials. The password can be
+stored locally to avoid retyping it:
+
+----
+git config --global credential.helper store
+git pull
+----
+
+To create changes as users of Gerrit would, run:
+
+----
+git push origin HEAD:refs/for/master
+----
+
 [[run_daemon]]
 === Running the Daemon
 
@@ -138,7 +160,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite \
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE \
      --console-log
 ----
 
@@ -170,7 +192,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite -s
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE -s
 ----
 
 NOTE: To learn why using `java -jar` isn't sufficient, see
@@ -194,27 +216,24 @@
 CAUTION: When using the Inspector, be careful not to modify the internal state
 of the system.
 
-== Switching between branches
 
-When using `git checkout` without `--recurse-submodules` to switch between
-branches, submodule revisions are not altered, which can result in:
+== Setup for backend developers
 
-*  Incorrect or unneeded plugin revisions.
-*  Missing plugins.
+=== Configuring Eclipse
 
-After you switch branches, ensure that you have the correct versions of
-the submodules.
+To use the Eclipse IDE for development, see
+link:dev-eclipse.html[Eclipse Setup].
 
-CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
-directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
-link:https://git-scm.com/docs/git-clean[git-clean].
+To configure the Eclipse workspace with Bazel, see
+link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
 
-Run the following:
+=== Configuring IntelliJ IDEA
 
-----
-  git submodule update
-  git clean -ffd
-----
+See <<dev-intellij#,IntelliJ Setup>> for details.
+
+== Setup for frontend developers
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md[Frontend Developer Setup].
+
 
 GERRIT
 ------
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 5411927..98a3df5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -91,7 +91,7 @@
 
 * `gerrit-maven`:
 +
-Bucket to store Gerrit Subproject Artifacts (e.g. `gwtorm` etc.).
+Bucket to store Gerrit Subproject Artifacts (e.g. Prolog Cafe).
 
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
deleted file mode 100644
index 1a8b501..0000000
--- a/Documentation/dev-release-jgit.txt
+++ /dev/null
@@ -1,52 +0,0 @@
-= Making a Snapshot Release of JGit
-
-This step is only necessary if we need to create an unofficial JGit
-snapshot release and publish it to the
-link:https://developers.google.com/storage/[Google Cloud Storage].
-
-[[prepare-environment]]
-== Prepare the Maven Environment
-
-First, make sure you have done the necessary
-link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
-configuration in Maven `settings.xml`].
-
-To apply the necessary settings in JGit's `pom.xml`, follow the instructions
-in link:dev-release-deploy-config.html#deploy-configuration-subprojects[
-Configuration for Subprojects in `pom.xml`], or apply the provided diff by
-executing the following command in the JGit workspace:
-
-----
-  git apply /path/to/gerrit/tools/jgit-snapshot-deploy-pom.diff
-----
-
-[[prepare-release]]
-== Prepare the Release
-
-Since JGit has its own release process we do not push any release tags. Instead
-we will use the output of `git describe` as the version of the current JGit
-snapshot.
-
-In the JGit workspace, execute the following command:
-
-----
-  ./tools/version.sh --release $(git describe)
-----
-
-[[publish-release]]
-== Publish the Release
-
-To deploy the new snapshot, execute the following command in the JGit
-workspace:
-
-----
-  mvn deploy
-----
-
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index ca33ef8..9e1744c 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -98,7 +98,7 @@
 Tag the plugins:
 
 ----
-  git submodule foreach git tag -s -m "v$version" "v$version"
+  git submodule foreach '[ "$path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
 ----
 
 [[build-gerrit]]
@@ -376,7 +376,7 @@
 feature-deprecations that we were holding off on to have a stable release where
 the feature is still contained, but marked as deprecated.
 
-See link:dev-contributing.html#deprecating-features[Deprecating features] for
+See link:dev-processes.html#deprecating-features[Deprecating features] for
 details.
 
 GERRIT
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
new file mode 100644
index 0000000..9dbc450
--- /dev/null
+++ b/Documentation/dev-roles.txt
@@ -0,0 +1,378 @@
+= Gerrit Code Review - Supporting Roles
+
+As an open source project Gerrit has a large community of people
+driving the project forward. There are many ways to engage with
+the project and get involved.
+
+[[supporter]]
+== Supporter
+
+Supporters are individuals who help the Gerrit project and the Gerrit
+community in any way. This includes users that provide feedback to the
+Gerrit community or get in touch by other means.
+
+There are many possibilities to support the project, e.g.:
+
+* get involved in discussions on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (post your questions, provide feedback, share your
+  experiences, help other users)
+* attend community events like user summits (see
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar])
+* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues]
+  and help to clarify existing issues
+* provide feedback on
+  link:https://www.gerritcodereview.com/releases-readme.html[new
+  releases and release candidates]
+* review
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and help to verify that they work as advertised, comment if you like
+  or dislike a feature
+* serve as contact person for a proprietary Gerrit installation and
+  channel feedback from users back to the Gerrit community
+
+Supporters can:
+
+* post on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (Please note that the `repo-discuss` mailing list is
+  managed to prevent spam posts. This means posts from new participants
+  must be approved manually before they appear on the mailing list.
+  Approvals normally happen within 1 work day. Posts of people who
+  participate in mailing list discussions frequently are approved
+  automatically)
+* comment on
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and vote from `-1` to `+1` on the `Code-Review` label (these votes
+  are important to understand the interest in a change and to address
+  concerns early, however link:#maintainer[maintainers] can
+  overrule/ignore these votes)
+* download changes to try them out, feedback can be provided as
+  comments and by voting (preferably on the `Verified` label,
+  permissions to vote on the `Verified` label are granted by request,
+  see below)
+* file issues in the link:https://bugs.chromium.org/p/gerrit/issues/list[
+  issue tracker] and comment on existing issues
+* support the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+
+Supporters who want to engage further can get additional privileges
+on request (ask for it on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list):
+
+* become member of the `gerrit-verifiers` group, which allows to:
+** vote on the `Verified` and `Code-Style` labels
+** edit hashtags on all changes
+** edit topics on all open changes
+** abandon changes
+* approve posts to the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list
+* administrate issues in the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+
+Supporters can become link:#contributor[contributors] by signing a
+contributor license agreement and contributing code to the Gerrit
+project.
+
+[[contributor]]
+== Contributor
+
+Everyone who has a valid link:dev-cla.html[contributor license
+agreement] and who has link:dev-contributing.html[contributed] at least
+one change to any project on
+link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com] is a contributor.
+
+Contributions can be:
+
+* new features
+* bug fixes
+* code cleanups
+* documentation updates
+* release notes updates
+* propose link:#dev-design-docs[design docs] as part of the
+  link:dev-contributing.html#design-driven-contribution-process[
+  design-driven contribution process]
+* scripts which are of interest to the community
+
+Contributors have all the permissions that link:#supporter[supporters]
+have. In addition they have signed a link:dev-cla.html[contributor
+license agreement] which enables them to push changes.
+
+Regular contributors can ask to be added to the `gerrit-verifiers`
+group, which allows to:
+
+* add patch sets to changes of other users
+* propose project config changes (push changes for the
+  `refs/meta/config` branch
+
+Being member of the `gerrit-verifiers` group includes further
+permissions (see link:#supporter[supporter] section above).
+
+It's highly appreciated if contributors engage in code reviews,
+link:dev-design-docs.html#review[design reviews] and mailing list
+discussions. If wanted, contributors can also serve as link:#mentor[
+mentors] to support other contributors with getting their features
+done.
+
+Contributors may also be invited to join the Gerrit hackathons which
+happen regularly (e.g. twice a year). Hackathons are announced on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list (also see
+link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+community calendar]).
+
+Outstanding contributors that are actively engaged in the community, in
+activities outlined above, may be nominated as link:#maintainer[
+maintainers].
+
+[[maintainer]]
+== Maintainer
+
+Maintainers are the gatekeepers of the project and are in charge of
+approving and submitting changes. Refer to the project homepage for
+the link:https://www.gerritcodereview.com/members.html#maintainers[
+list of current maintainers].
+
+Maintainers should only approve changes that:
+
+* they fully understand
+* are in line with the project vision and project scope that are
+  defined by the link:dev-processes.html#steering-committee[engineering steering
+  committee], and should consult them, when in doubt
+* meet the quality expectations of the project (well-tested, properly
+  documented, scalable, backwards-compatible)
+* implement usable features or bug fixes (no incomplete/unusable
+  things)
+* are not authored by themselves (exceptions are changes which are
+  trivial according to the judgment of the maintainer and changes that
+  are required by the release process and branch management)
+
+Maintainers are trusted to assess changes, but are also expected to
+align with the other maintainers, especially if large new features are
+being added.
+
+Maintainers are highly encouraged to dedicate some of their time to the
+following tasks (but are not required to do so):
+
+* reviewing changes
+* mailing list discussions and support
+* bug fixing and bug triaging
+* supporting the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+* serving as link:#mentor[mentor]
+* doing releases (see link#release-manager[release manager])
+
+Maintainers can:
+
+* approve changes (vote `+2` on the `Code-Review` label); when
+  approving changes, `-1` votes on the `Code-Review` label can be
+  ignored if there is a good reason, in this case the reason should be
+  clearly communicated on the change
+* submit changes
+* block submission of changes if they disagree with how a feature is
+  being implemented (vote `-2` on the `Code-Review` label), but their
+  vote can be overruled by the steering committee, see
+  link:dev-processes.html#project-governance[Project Governance]
+* nominate new maintainers and vote on nominations (see below)
+* administrate the link:https://groups.google.com/d/forum/repo-discuss[
+  mailing list], the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+  and the link:https://www.gerritcodereview.com/[homepage]
+* gain permissions to do Gerrit releases and publish release artifacts
+* create new projects and groups on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com]
+* administrate the Gerrit projects on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com] (e.g. edit ACLs, update project
+  configuration)
+* create events in the
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar]
+* discuss with other maintainers on the private maintainers mailing
+  list and Slack channel
+
+In addition, maintainers from Google can:
+
+* approve/reject changes that update project dependencies (vote `-1` to
+  `+1` on the `Library-Compliance` label), see
+  link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+* edit permissions on the Gerrit core projects
+
+[[maintainer-election]]
+Maintainers can nominate new maintainers by posting a nomination on the
+non-public maintainers mailing list. Nominations should stay open for
+at least 14 calendar days so that all maintainers have a chance to
+vote. To be approved as maintainer a minimum of 5 positive votes and no
+negative votes is required. This means if 5 positive votes without
+negative votes have been reached and 14 calendar days have passed, any
+maintainer can close the vote and welcome the new maintainer. Extending
+the voting period during holiday season or if there are not enough
+votes is possible, but the voting period should not exceed 1 month. If
+there are negative votes that are considered unjustified, the
+link:dev-processes.html#steering-committee[engineering steering
+committee] may get involved to decide whether the new maintainer can be
+accepted anyway.
+
+To become a maintainer, a link:#contributor[contributor] should have a
+history of deep technical contributions across different parts of the
+core Gerrit codebase. However, it is not required to be an expert on
+everything. Things that we want to see from potential maintainers
+include:
+
+* high quality code contributions
+* high quality code reviews
+* activity on the mailing list
+
+[[steering-committee-member]]
+== Engineering Steering Committee Member
+
+The Gerrit project has an Engineering Steering Committee (ESC) that
+governs the project, see link:dev-processes.html#project-governance[Project Governance].
+
+Members of the steering committee are expected to act in the interest
+of the Gerrit project and the whole Gerrit community. Refer to the project
+homepage for the link:https://www.gerritcodereview.com/members.html#engineering-steering-committee[
+list of current committee members].
+
+For those that are familiar with scrum, the steering committee member
+role is similar to the role of an agile product owner.
+
+Steering committee members must be able to dedicate sufficient time to
+their role so that the steering committee can satisfy its
+responsibilities and live up to the promise of answering incoming
+requests in a timely manner.
+
+Community members may submit new items under the
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:ESC[ESC component]
+in the issue tracker, or add that component to existing items, to raise them to
+the attention of ESC members.
+
+Community members may contact the ESC members directly using
+mailto:gerritcodereview-esc@googlegroups.com[this mailing list].
+This is a group that remains private between the individual community
+member and ESC members.
+
+link:#maintainer[Maintainers] can become steering committee member by
+election, or by being appointed by Google (only for the seats that
+belong to Google).
+
+[[mentor]]
+== Mentor
+
+A mentor is a link:#maintainer[maintainer] or link:#contributor[
+contributor] who is assigned to support the development of a feature
+that was specified in a link:dev-design-docs.html[design doc] and was
+approved by the link:dev-processes.html#steering-committee[steering
+committee].
+
+The goal of the mentor is to make the feature successful by:
+
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
+
+The implementation is fully done by the contributor, but optionally
+mentors can help out with contributing some changes.
+
+link:#maintainer[Maintainers] and link:#contributor[contributors] can
+volunteer to generally serve as mentors, or to mentor specific features
+(e.g. if they see an upcoming feature on the roadmap that they are
+interested in). To volunteer as mentor, contact the
+link:dev-processes.html#steering-committee[steering committee] or
+comment on a change that adds a link:dev-design-docs.html#propose[
+design doc].
+
+[[community-manager]]
+== Community Manager
+
+Community managers should act as stakeholders for the Gerrit community
+and focus on the health of the community. Refer to the project homepage
+for the link:https://www.gerritcodereview.com/members.html#community-managers[
+list of current community managers].
+
+Tasks:
+
+* act as stakeholder for the Gerrit community towards the
+  link:dev-processes.html#steering-committee[steering committee]
+* ensure that the link:dev-contributing.html#mentorship[mentorship
+  process] works
+* deescalate conflicts in the Gerrit community
+* constantly improve community processes (e.g. contribution process)
+* watch out for community issues and address them proactively
+* serve as contact person for community issues
+
+Community members may submit new items under the
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:Community[Community component]
+backlog, for community managers to refine. Only public topics should be
+issued through that backlog.
+
+Sensitive topics are to be privately discussed using
+mailto:gerritcodereview-community-managers@googlegroups.com[this mailing list].
+This is a group that remains private between the individual community
+member and community managers.
+
+The community managers should be a pair or trio that shares the work:
+
+* One Googler that is appointed by Google.
+* One or two non-Googlers, elected by the community if there are more
+  than two candidates. If there is no candidate, we only have the one
+  community manager from Google.
+
+Community managers must not be link:#steering-committee-member[
+steering committee members] at the same time so that they can represent
+the community without conflict of interest.
+
+Anybody from the Gerrit community can candidate as community manager.
+This means, in contrast to candidating for the ESC, candidating as
+community manager is not limited to Gerrit maintainers. Otherwise the
+nomination process, election process and election period for the
+non-Google community manager are the same as for
+link:dev-processes.html#steering-committee-election[steering committee
+members].
+
+[[release-manager]]
+== Release Manager
+
+Each major Gerrit release is driven by a Gerrit link:#maintainer[
+maintainer], the so called release manager.
+
+The release manager is responsible for:
+
+* identifying release blockers and informing about them
+* creating stable branches and updating version numbers
+* creating release candidates, the final major release and minor
+  releases
+* announcing releases on the mailing list and collecting feedback
+* ensuring that releases meet minimal quality expectations (Gerrit
+  starts, upgrade from previous version works)
+* publishing release artifacts
+* ensuring quality and completeness of the release notes
+* cherry-picking bug fixes, see link:dev-processes.html#backporting[
+  Backporting to stable branches]
+* estimating the risk of new features that are added on stable
+  branches, see link:dev-processes.html#dev-in-stable-branches[
+  Development in stable branches]
+
+Before each release, the release manager is appointed by consensus among
+the maintainers. Volunteers are welcome, but it's also a goal to fairly
+share this work between maintainers and contributing companies.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
new file mode 100644
index 0000000..ae40ea6
--- /dev/null
+++ b/Documentation/dev-starter-projects.txt
@@ -0,0 +1,14 @@
+= Gerrit Code Review - Starter Projects
+
+We have created a
+link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
+category in the issue tracker and try to assign easy hack projects to it. If in
+doubt, do not hesitate to ask on the developer
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/error-change-does-not-belong-to-project.txt b/Documentation/error-change-does-not-belong-to-project.txt
deleted file mode 100644
index 21596b1..0000000
--- a/Documentation/error-change-does-not-belong-to-project.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-= change ... does not belong to project ...
-
-With this error message Gerrit rejects to push a commit to a change
-that belongs to another project.
-
-This error message means that the user explicitly pushed a commit to
-a change that belongs to another project by specifying it as target
-ref. This way of adding a new patch set to a change is deprecated as
-explained link:user-upload.html#manual_replacement_mapping[here]. It is recommended to only rely on Change-Ids for
-link:user-upload.html#push_replace[replacing changes].
-
-
-GERRIT
-------
-Part of link:error-messages.html[Gerrit Error Messages]
-
-SEARCHBOX
----------
diff --git a/Documentation/error-change-not-found.txt b/Documentation/error-change-not-found.txt
deleted file mode 100644
index df99388..0000000
--- a/Documentation/error-change-not-found.txt
+++ /dev/null
@@ -1,17 +0,0 @@
-= change ... not found
-
-With this error message Gerrit rejects to push a commit to a change
-that cannot be found.
-
-This error message means that the user explicitly pushed a commit to
-a non-existing change by specifying it as target ref. This way of
-adding a new patch set to a change is deprecated as explained link:user-upload.html#manual_replacement_mapping[here].
-It is recommended to only rely on Change-Ids for link:user-upload.html#push_replace[replacing changes].
-
-
-GERRIT
-------
-Part of link:error-messages.html[Gerrit Error Messages]
-
-SEARCHBOX
----------
diff --git a/Documentation/error-messages.txt b/Documentation/error-messages.txt
index b523663..eedae39 100644
--- a/Documentation/error-messages.txt
+++ b/Documentation/error-messages.txt
@@ -9,8 +9,6 @@
 
 * link:error-branch-not-found.html[branch ... not found]
 * link:error-change-closed.html[change ... closed]
-* link:error-change-does-not-belong-to-project.html[change ... does not belong to project ...]
-* link:error-change-not-found.html[change ... not found]
 * link:error-commit-already-exists.html[commit already exists]
 * link:error-contains-banned-commit.html[contains banned commit ...]
 * link:error-has-duplicates.html[... has duplicates]
@@ -35,7 +33,6 @@
 * link:error-same-change-id-in-multiple-changes.html[same Change-Id in multiple changes]
 * link:error-too-many-commits.html[too many commits]
 * link:error-upload-denied.html[Upload denied for project \'...']
-* link:error-push-refschanges-not-allowed.html[upload to refs/changes not allowed]
 * link:error-not-allowed-to-upload-merges.html[you are not allowed to upload merges]
 
 
diff --git a/Documentation/error-push-refschanges-not-allowed.txt b/Documentation/error-push-refschanges-not-allowed.txt
deleted file mode 100644
index 2bbdc3e..0000000
--- a/Documentation/error-push-refschanges-not-allowed.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-= upload to refs/changes not allowed
-
-Pushing to `refs/changes/` is deprecated and is not allowed on this Gerrit server.
-See the documentation for link:user-upload.html#push_create[creating changes] for
-alternate ways to push to existing changes.
-
-
-GERRIT
-------
-Part of link:error-messages.html[Gerrit Error Messages]
-
-SEARCHBOX
----------
diff --git a/Documentation/i18n-readme.txt b/Documentation/i18n-readme.txt
deleted file mode 100644
index 180fc53..0000000
--- a/Documentation/i18n-readme.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-= Gerrit Code Review - i18n
-
-Aside from actually writing translations, there are some issues with
-the way the code produces output.  Most of the UI should support
-right-to-left (RTL) languages.
-
-== Labels
-
-Labels and their values are defined in project.config by the Gerrit
-administrator or project owners.  Only a single translation of these
-strings is supported.
-
-== /Gerrit Gerrit.html
-
-* The title of the host page is not translated.
-
-* The <noscript> tag is not translated.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 557cf90..b44f1b2 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -10,7 +10,11 @@
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
 
-== Guides
+== Contributor Guides
+. link:dev-community.html[Gerrit Community]
+. link:dev-community.html#how-to-contribute[How to Contribute]
+
+== User Guides
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
 . link:https://source.android.com/source/developing[Default Android Workflow] (external)
@@ -72,28 +76,6 @@
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
 
-== Developer
-. Getting Started
-.. link:dev-readme.html[Developer Setup]
-.. link:dev-bazel.html[Building with Bazel]
-.. link:dev-eclipse.html[Eclipse Setup]
-.. link:dev-intellij.html[IntelliJ Setup]
-.. link:dev-contributing.html[Contributing to Gerrit]
-. Plugin Development
-.. link:dev-plugins.html[Developing Plugins]
-.. link:dev-build-plugins.html[Building Gerrit plugins]
-.. link:js-api.html[JavaScript Plugin API]
-.. link:config-validation.html[Validation Interfaces]
-.. link:dev-stars.html[Starring Changes]
-.. link:quota.html[Quota Enforcement]
-. link:dev-design.html[System Design]
-. link:i18n-readme.html[i18n Support]
-
-== Maintainer
-. link:dev-release.html[Making a Gerrit Release]
-. link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
-. link:dev-release-jgit.html[Making a Release of JGit]
-
 == Concepts
 . link:config-labels.html[Review Labels]
 . link:access-control.html[Access Controls]
@@ -104,7 +86,7 @@
 == Resources
 * link:licenses.html[Licenses and Notices]
 * link:https://www.gerritcodereview.com/[Homepage]
-* link:https://www.gerritcodereview.com/download/index.html[Downloads]
+* link:https://gerrit-releases.storage.googleapis.com/index.html[Downloads]
 * link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
 * link:https://gerrit.googlesource.com/gerrit[Source Code]
 * link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review]
diff --git a/Documentation/install.txt b/Documentation/install.txt
index aaefc86f..09ebbba 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -46,7 +46,7 @@
 == Download Gerrit
 
 Current and past binary releases of Gerrit can be obtained from
-the link:https://www.gerritcodereview.com/download/index.html[
+the link:https://gerrit-releases.storage.googleapis.com/index.html[
 Gerrit Releases site].
 
 Download any current `*.war` package. The war will be referred to as
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 1fba1dc..b4f799c2 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -28,7 +28,7 @@
 modify. To get this code, he runs the following `git clone` command:
 
 ----
-clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
+git clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
 ----
 
 After he clones the repository, he runs a couple of commands to add a
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 03f3880..e502cdd 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -712,36 +712,6 @@
 
 The following preferences can be configured:
 
-- [[review-category]]`Display In Review Category`:
-+
-This setting controls how the values of the review labels in change
-lists and dashboards are visualized.
-+
-** `None`:
-+
-For each review label only the voting value is shown. Approvals are
-rendered as a green check mark icon, vetoes as a red X icon.
-+
-** `Show Name`:
-+
-For each review label the voting value is shown together with the full
-name of the voting user.
-+
-** `Show Email`:
-+
-For each review label the voting value is shown together with the email
-address of the voting user.
-+
-** `Show Username`:
-+
-For each review label the voting value is shown together with the
-username of the voting user.
-+
-** `Show Abbreviated Name`:
-+
-For each review label the voting value is shown together with the
-initials of the full name of the voting user.
-
 - [[page-size]]`Maximum Page Size`:
 +
 The maximum number of entries that are shown on one page, e.g. used
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 4ef2a6c..030541d 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -24,123 +24,17 @@
 The plugin instance is passed to the plugin's initialization function
 and provides a number of utility services to plugin authors.
 
-[[self_delete]]
-=== self.delete() / self.del()
-Issues a DELETE REST API request to the Gerrit server.
-
-.Signature
-[source,javascript]
-----
-Gerrit.delete(url, callback)
-Gerrit.del(url, callback)
-----
-
-* url: URL relative to the plugin's URL space. The JavaScript
-  library prefixes the supplied URL with `/plugins/{getPluginName}/`.
-
-* callback: JavaScript function to be invoked with the parsed
-  JSON result of the API call. DELETE methods often return
-  `204 No Content`, which is passed as null.
-
-[[self_get]]
-=== self.get()
-Issues a GET REST API request to the Gerrit server.
-
-.Signature
-[source,javascript]
-----
-self.get(url, callback)
-----
-
-* url: URL relative to the plugin's URL space. The JavaScript
-  library prefixes the supplied URL with `/plugins/{getPluginName}/`.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
 [[self_getServerInfo]]
 === self.getServerInfo()
 Returns the server's link:rest-api-config.html#server-info[ServerInfo]
 data.
 
-[[self_getCurrentUser]]
-=== self.getCurrentUser()
-Returns the currently signed in user's AccountInfo data; empty account
-data if no user is currently signed in.
-
-[[Gerrit_getUserPreferences]]
-=== Gerrit.getUserPreferences()
-Returns the preferences of the currently signed in user; the default
-preferences if no user is currently signed in.
-
-[[Gerrit_refreshUserPreferences]]
-=== Gerrit.refreshUserPreferences()
-Refreshes the preferences of the current user.
-
 [[self_getPluginName]]
 === self.getPluginName()
 Returns the name this plugin was installed as by the server
 administrator. The plugin name is required to access REST API
 views installed by the plugin, or to access resources.
 
-[[self_post]]
-=== self.post()
-Issues a POST REST API request to the Gerrit server.
-
-.Signature
-[source,javascript]
-----
-self.post(url, input, callback)
-----
-
-* url: URL relative to the plugin's URL space. The JavaScript
-  library prefixes the supplied URL with `/plugins/{getPluginName}/`.
-
-* input: JavaScript object to serialize as the request payload.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
-[source,javascript]
-----
-self.post(
-  '/my-servlet',
-  {start_build: true, platform_type: 'Linux'},
-  function (r) {});
-----
-
-[[self_put]]
-=== self.put()
-Issues a PUT REST API request to the Gerrit server.
-
-.Signature
-[source,javascript]
-----
-self.put(url, input, callback)
-----
-
-* url: URL relative to the plugin's URL space. The JavaScript
-  library prefixes the supplied URL with `/plugins/{getPluginName}/`.
-
-* input: JavaScript object to serialize as the request payload.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
-[source,javascript]
-----
-self.put(
-  '/builds',
-  {start_build: true, platform_type: 'Linux'},
-  function (r) {});
-----
-
 [[self_on]]
 === self.on()
 Register a JavaScript callback to be invoked when events occur within
@@ -149,7 +43,7 @@
 .Signature
 [source,javascript]
 ----
-Gerrit.on(event, callback);
+self.on(event, callback);
 ----
 
 * event: A supported event type. See below for description.
@@ -194,39 +88,26 @@
   This event can be used to register a new language highlighter with
   the highlight.js library before syntax highlighting begins.
 
-[[self_onAction]]
-=== self.onAction()
-Register a JavaScript callback to be invoked when the user clicks
-on a button associated with a server side `UiAction`.
+[[self_changeActions]]
+=== self.changeActions()
+Returns an instance of ChangeActions API.
 
 .Signature
 [source,javascript]
 ----
-self.onAction(type, view_name, callback);
+self.changeActions();
 ----
 
-* type: `'change'`, `'edit'`, `'revision'`, `'project'`, or `'branch'`
-  indicating which type of resource the `UiAction` was bound to
-  in the server.
-
-* view_name: string appearing in URLs to name the view. This is the
-  second argument of the `get()`, `post()`, `put()`, and `delete()`
-  binding methods in a `RestApiModule`.
-
-* callback: JavaScript function to invoke when the user clicks. The
-  function will be passed a link:#ActionContext[action context].
-
 [[self_screen]]
 === self.screen()
-Register a JavaScript callback to be invoked when the user navigates
+Register a module to be attached when the user navigates
 to an extension screen provided by the plugin. Extension screens are
 usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
-The callback can populate the DOM with the screen's contents.
 
 .Signature
 [source,javascript]
 ----
-self.screen(pattern, callback);
+self.screen(pattern, opt_moduleName);
 ----
 
 * pattern: URL token pattern to identify the screen. Argument can be
@@ -234,52 +115,34 @@
   If a RegExp is used the matching groups will be available inside of
   the context as `token_match`.
 
-* callback: JavaScript function to invoke when the user navigates to
+* opt_moduleName: The module to load when the user navigates to
   the screen. The function will be passed a link:#ScreenContext[screen context].
 
-[[self_settingsScreen]]
-=== self.settingsScreen()
-Register a JavaScript callback to be invoked when the user navigates
-to an extension settings screen provided by the plugin. Extension settings
-screens are automatically linked from the settings menu under the given
-menu entry.
-The callback can populate the DOM with the screen's contents.
+[[self_settings]]
+=== self.settings()
+Returns the Settings API.
 
 .Signature
 [source,javascript]
 ----
-self.settingsScreen(path, menu, callback);
+self.settings();
 ----
 
-* path: URL path to identify the settings screen.
-
-* menu: The name of the menu entry in the settings menu that should
-  link to the settings screen.
-
-* callback: JavaScript function to invoke when the user navigates to
-  the settings screen. The function will be passed a
-  link:#SettingsScreenContext[settings screen context].
-
-[[self_panel]]
-=== self.panel()
-Register a JavaScript callback to be invoked when a screen with the
-given extension point is loaded.
-The callback can populate the DOM with the panel's contents.
+[[self_registerCustomComponent]]
+=== self.registerCustomComponent()
+Register a custom component to a specific endpoint.
 
 .Signature
 [source,javascript]
 ----
-self.panel(extensionpoint, callback);
+self.registerCustomComponent(endpointName, opt_moduleName, opt_options);
 ----
 
-* extensionpoint: The name of the extension point that marks the
-  position where the panel is added to an existing screen. The
-  available extension points are described in the
-  link:dev-plugins.html#panels[plugin development documentation].
+* endpointName: The endpoint this plugin should be reigistered to.
 
-* callback: JavaScript function to invoke when a screen with the
-  extension point is loaded. The function will be passed a
-  link:#PanelContext[panel context].
+* opt_moduleName: The module name the custom component will use.
+
+* opt_options: Options to register this custom component.
 
 [[self_url]]
 === self.url()
@@ -293,398 +156,260 @@
 self.url('/static/icon.png');  // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
 ----
 
-
-[[ActionContext]]
-== Action Context
-A new action context is passed to the `onAction` callback function
-each time the associated action button is clicked by the user. A
-context is initialized with sufficient state to issue the associated
-REST API RPC.
-
-[[context_action]]
-=== context.action
-An link:rest-api-changes.html#action-info[ActionInfo] object instance
-supplied by the server describing the UI button the user used to
-invoke the action.
-
-[[context_call]]
-=== context.call()
-Issues the REST API call associated with the action. The HTTP method
-used comes from `context.action.method`, hiding the JavaScript from
-needing to care.
+[[self_restApi]]
+=== self.restApi()
+Returns an instance of the Plugin REST API.
 
 .Signature
 [source,javascript]
 ----
-context.call(input, callback)
+self.restApi(prefix_url)
 ----
 
-* input: JavaScript object to serialize as the request payload. This
-  parameter is ignored for GET and DELETE methods.
+* prefix_url: Base url for subsequent .get(), .post() etc requests.
 
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
+[[PluginRestAPI]]
+== Plugin Rest API
 
-[source,javascript]
-----
-context.call(
-  {message: "..."},
-  function (result) {
-    // ... use result here ...
-  });
-----
-
-[[context_change]]
-=== context.change
-When the action is invoked on a change a
-link:rest-api-changes.html#change-info[ChangeInfo] object instance
-describing the change. Available fields of the ChangeInfo may vary
-based on the options used by the UI when it loaded the change.
-
-[[context_delete]]
-=== context.delete()
-Issues a DELETE REST API call to the URL associated with the action.
+[[plugin_rest_delete]]
+=== restApi.delete()
+Issues a DELETE REST API request to the Gerrit server.
+Returns a promise with the response of the request.
 
 .Signature
 [source,javascript]
 ----
-context.delete(callback)
+restApi.delete(url)
 ----
 
-* callback: JavaScript function to be invoked with the parsed
-  JSON result of the API call. DELETE methods often return
-  `204 No Content`, which is passed as null.
+* url: URL relative to the base url.
 
-[source,javascript]
-----
-context.delete(function () {});
-----
-
-[[context_get]]
-=== context.get()
-Issues a GET REST API call to the URL associated with the action.
+[[plugin_rest_get]]
+=== restApi.get()
+Issues a GET REST API request to the Gerrit server.
+Returns a promise with the response of the request.
 
 .Signature
 [source,javascript]
 ----
-context.get(callback)
+restApi.get(url)
 ----
 
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
+* url: URL relative to the base url.
 
-[source,javascript]
-----
-context.get(function (result) {
-  // ... use result here ...
-});
-----
-
-[[context_go]]
-=== context.go()
-Go to a screen. Shorthand for link:#Gerrit_go[`Gerrit.go()`].
-
-[[context_hide]]
-=== context.hide()
-Hide the currently visible popup displayed by
-link:#context_popup[`context.popup()`].
-
-[[context_post]]
-=== context.post()
-Issues a POST REST API call to the URL associated with the action.
+[[plugin_rest_post]]
+=== restApi.post()
+Issues a POST REST API request to the Gerrit server.
+Returns a promise with the response of the request.
 
 .Signature
 [source,javascript]
 ----
-context.post(input, callback)
+restApi.post(url, opt_payload, opt_errFn, opt_contentType)
 ----
 
-* input: JavaScript object to serialize as the request payload.
+* url: URL relative to the base url.
 
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
+* opt_payload: JavaScript object to serialize as the request payload.
+
+* opt_errFn: JavaScript function to be invoked when error occured.
+
+* opt_contentType: Content-Type to be sent along with the request.
 
 [source,javascript]
 ----
-context.post(
-  {message: "..."},
-  function (result) {
-    // ... use result here ...
-  });
+restApi.post(
+  '/my-servlet',
+  {start_build: true, platform_type: 'Linux'});
 ----
 
-[[context_popup]]
-=== context.popup()
-
-Displays a small popup near the activation button to gather
-additional input from the user before executing the REST API RPC.
-
-The caller is always responsible for closing the popup with
-link#context_hide[`context.hide()`]. Gerrit will handle closing a
-popup if the user presses `Escape` while keyboard focus is within
-the popup.
+[[plugin_rest_put]]
+=== restApi.put()
+Issues a PUT REST API request to the Gerrit server.
+Returns a promise with the response of the request.
 
 .Signature
 [source,javascript]
 ----
-context.popup(element)
+restApi.put(url, opt_payload, opt_errFn, opt_contentType)
 ----
 
-* element: an HTML DOM element to display as the body of the
-  popup. This is typically a `div` element but can be any valid HTML
-  element. CSS can be used to style the element beyond the defaults.
+* url: URL relative to the base url.
 
-A common usage is to gather more input:
+* opt_payload: JavaScript object to serialize as the request payload.
+
+* opt_errFn: JavaScript function to be invoked when error occured.
+
+* opt_contentType: Content-Type to be sent along with the request.
 
 [source,javascript]
 ----
-self.onAction('revision', 'start-build', function (c) {
-  var l = c.checkbox();
-  var m = c.checkbox();
-  c.popup(c.div(
-    c.div(c.label(l, 'Linux')),
-    c.div(c.label(m, 'Mac OS X')),
-    c.button('Build', {onclick: function() {
-      c.call(
-        {
-          commit: c.revision.name,
-          linux: l.checked,
-          mac: m.checked,
-        },
-        function() { c.hide() });
-    });
-});
+restApi.put(
+  '/builds',
+  {start_build: true, platform_type: 'Linux'});
 ----
 
-[[context_put]]
-=== context.put()
-Issues a PUT REST API call to the URL associated with the action.
+[[ChangeActions]]
+== Change Actions API
+A new Change Actions API instance will be created when `changeActions()`
+is invoked.
+
+[[change_actions_add]]
+=== changeActions.add()
+Adds a new action to the change actions section.
+Returns the key of the newly added action.
 
 .Signature
 [source,javascript]
 ----
-context.put(input, callback)
+changeActions.add(type, label)
 ----
 
-* input: JavaScript object to serialize as the request payload.
+* type: The type of the action, either `change` or `revision`.
 
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
+* label: The label to be used in UI for this action.
 
 [source,javascript]
 ----
-context.put(
-  {message: "..."},
-  function (result) {
-    // ... use result here ...
-  });
+changeActions.add("change", "test")
 ----
 
-[[context_refresh]]
-=== context.refresh()
-Refresh the current display. Shorthand for
-link:#Gerrit_refresh[`Gerrit.refresh()`].
-
-[[context_revision]]
-=== context.revision
-When the action is invoked on a specific revision of a change,
-a link:rest-api-changes.html#revision-info[RevisionInfo]
-object instance describing the revision. Available fields of the
-RevisionInfo may vary based on the options used by the UI when it
-loaded the change.
-
-[[context_project]]
-=== context.project
-When the action is invoked on a specific project,
-the name of the project.
-
-=== HTML Helpers
-The link:#ActionContext[action context] includes some HTML helper
-functions to make working with DOM based widgets less painful.
-
-* `br()`: new `<br>` element.
-
-* `button(label, options)`: new `<button>` with the string `label`
-  wrapped inside of a `div`. The optional `options` object may
-  define `onclick` as a function to be invoked upon clicking. This
-  calling pattern avoids circular references between the element
-  and the onclick handler.
-
-* `checkbox()`: new `<input type='checkbox'>` element.
-* `div(...)`: a new `<div>` wrapping the (optional) arguments.
-* `hr()`: new `<hr>` element.
-
-* `label(c, label)`: a new `<label>` element wrapping element `c`
-  and the string `label`. Used to wrap a checkbox with its label,
-  `label(checkbox(), 'Click Me')`.
-
-* `prependLabel(label, c)`: a new `<label>` element wrapping element `c`
-  and the string `label`. Used to wrap an input field with its label,
-  `prependLabel('Greeting message', textfield())`.
-
-* `textarea(options)`: new `<textarea>` element. The options
-  object may optionally include `rows` and `cols`. The textarea
-  comes with an onkeypress handler installed to play nicely with
-  Gerrit's keyboard binding system.
-
-* `textfield()`: new `<input type='text'>` element.  The text field
-  comes with an onkeypress handler installed to play nicely with
-  Gerrit's keyboard binding system.
-
-* `select(a,i)`: a new `<select>` element containing one `<option>`
-  element for each entry in the provided array `a`.  The option with
-  the index `i` will be pre-selected in the drop-down-list.
-
-* `selected(s)`: returns the text of the `<option>` element that is
-  currently selected in the provided `<select>` element `s`.
-
-* `span(...)`: a new `<span>` wrapping the (optional) arguments.
-
-* `msg(label)`: a new label.
-
-
-[[ScreenContext]]
-== Screen Context
-A new screen context is passed to the `screen` callback function
-each time the user navigates to a matching URL.
-
-[[screen_body]]
-=== screen.body
-Empty HTML `<div>` node the plugin should add its content to.  The
-node is already attached to the document, but is invisible.  Plugins
-must call `screen.show()` to display the DOM node.  Deferred display
-allows an implementor to partially populate the DOM, make remote HTTP
-requests, finish populating when the callbacks arrive, and only then
-make the view visible to the user.
-
-[[screen_token]]
-=== screen.token
-URL token fragment that activated this screen.  The value is identical
-to `screen.token_match[0]`.  If the URL is `/#/x/hello/list` the token
-will be `"list"`.
-
-[[screen_token_match]]
-=== screen.token_match
-Array of matching subgroups from the pattern specified to `screen()`.
-This is identical to the result of RegExp.exec. Index 0 contains the
-entire matching expression; index 1 the first matching group, etc.
-
-[[screen_onUnload]]
-=== screen.onUnload()
-Configures an optional callback to be invoked just before the screen
-is deleted from the browser DOM.  Plugins can use this callback to
-remove event listeners from DOM nodes, preventing memory leaks.
+[[change_actions_remove]]
+=== changeActions.remove()
+Removes an action from the change actions section.
 
 .Signature
 [source,javascript]
 ----
-screen.onUnload(callback)
+changeActions.remove(key)
 ----
 
-* callback: JavaScript function to be invoked just before the
-  `screen.body` DOM element is removed from the browser DOM.
-  This event happens when the user navigates to another screen.
+* key: The key of the action.
 
-[[screen.setTitle]]
-=== screen.setTitle()
-Sets the heading text to be displayed when the screen is visible.
-This is presented in a large bold font below the menus, but above the
-content in `screen.body`.  Setting the title also sets the window
-title to the same string, if it has not already been set.
+[[change_actions_addTapListener]]
+=== changeActions.addTapListener()
+Adds a tap listener to an action that will be invoked when the action
+is tapped.
 
 .Signature
 [source,javascript]
 ----
-screen.setPageTitle(titleText)
+changeActions.addTapListener(key, callback)
 ----
 
-[[screen.setWindowTitle]]
-=== screen.setWindowTitle()
-Sets the text to be displayed in the browser's title bar when the
-screen is visible.  Plugins should always prefer this method over
-trying to set `window.title` directly.  The window title defaults to
-the title given to `setTitle`.
+* key: The key of the action.
+
+* callback: JavaScript function to be invoked when action tapped.
+
+[source,javascript]
+----
+changeActions.addTapListener("__key_for_my_action__", () => {
+  // do something when my action gets clicked
+})
+----
+
+[[change_actions_removeTapListener]]
+=== changeActions.removeTapListener()
+Removes an existing tap listener on an action.
 
 .Signature
 [source,javascript]
 ----
-screen.setWindowTitle(titleText)
+changeActions.removeTapListener(key, callback)
 ----
 
-[[screen_show]]
-=== screen.show()
-Destroy the currently visible screen and display the plugin's screen.
-This method must be called after adding content to `screen.body`.
+* key: The key of the action.
 
-[[SettingsScreenContext]]
-== Settings Screen Context
-A new settings screen context is passed to the `settingsScreen` callback
-function each time the user navigates to a matching URL.
+* callback: JavaScript function to be removed.
 
-[[settingsScreen_body]]
-=== settingsScreen.body
-Empty HTML `<div>` node the plugin should add its content to.  The
-node is already attached to the document, but is invisible.  Plugins
-must call `settingsScreen.show()` to display the DOM node.  Deferred
-display allows an implementor to partially populate the DOM, make
-remote HTTP requests, finish populating when the callbacks arrive, and
-only then make the view visible to the user.
-
-[[settingsScreen_onUnload]]
-=== settingsScreen.onUnload()
-Configures an optional callback to be invoked just before the screen
-is deleted from the browser DOM.  Plugins can use this callback to
-remove event listeners from DOM nodes, preventing memory leaks.
+[[change_actions_setLabel]]
+=== changeActions.setLabel()
+Sets the label for an action.
 
 .Signature
 [source,javascript]
 ----
-settingsScreen.onUnload(callback)
+changeActions.setLabel(key, label)
 ----
 
-* callback: JavaScript function to be invoked just before the
-  `settingsScreen.body` DOM element is removed from the browser DOM.
-  This event happens when the user navigates to another screen.
+* key: The key of the action.
 
-[[settingsScreen.setTitle]]
-=== settingsScreen.setTitle()
-Sets the heading text to be displayed when the screen is visible.
-This is presented in a large bold font below the menus, but above the
-content in `settingsScreen.body`. Setting the title also sets the
-window title to the same string, if it has not already been set.
+* label: The label of the action.
+
+[[change_actions_setTitle]]
+=== changeActions.setTitle()
+Sets the title for an action.
 
 .Signature
 [source,javascript]
 ----
-settingsScreen.setPageTitle(titleText)
+changeActions.setTitle(key, title)
 ----
 
-[[settingsScreen.setWindowTitle]]
-=== settingsScreen.setWindowTitle()
-Sets the text to be displayed in the browser's title bar when the
-screen is visible.  Plugins should always prefer this method over
-trying to set `window.title` directly.  The window title defaults to
-the title given to `setTitle`.
+* key: The key of the action.
+
+* title: The title of the action.
+
+[[change_actions_setIcon]]
+=== changeActions.setIcon()
+Sets an icon for an action.
 
 .Signature
 [source,javascript]
 ----
-settingsScreen.setWindowTitle(titleText)
+changeActions.setIcon(key, icon)
 ----
 
-[[settingsScreen_show]]
-=== settingsScreen.show()
-Destroy the currently visible screen and display the plugin's screen.
-This method must be called after adding content to
-`settingsScreen.body`.
+* key: The key of the action.
+
+* icon: The name of the icon.
+
+[[change_actions_setEnabled]]
+=== changeActions.setEnabled()
+Sets an action to enabled or disabled.
+
+.Signature
+[source,javascript]
+----
+changeActions.setEnabled(key, enabled)
+----
+
+* key: The key of the action.
+
+* enabled: The status of the action, true to enable.
+
+[[change_actions_setActionHidden]]
+=== changeActions.setActionHidden()
+Sets an action to be hidden.
+
+.Signature
+[source,javascript]
+----
+changeActions.setActionHidden(type, key, hidden)
+----
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* hidden: True to hide the action, false to show the action.
+
+[[change_actions_setActionOverflow]]
+=== changeActions.setActionOverflow()
+Sets an action to show in overflow menu.
+
+.Signature
+[source,javascript]
+----
+changeActions.setActionOverflow(type, key, overflow)
+----
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* overflow: True to move the action to overflow menu, false to move
+  the action out of the overflow menu.
 
 [[PanelContext]]
 == Panel Context
@@ -713,10 +438,12 @@
 
 [[Gerrit_css]]
 === Gerrit.css()
+[WARNING]
+This method is deprecated. It doesn't work with Shadow DOM and
+will be removed in the future. Please, use link:pg-plugin-dev.html#plugin-styles[plugin.styles] instead.
+
 Creates a new unique CSS class and injects it into the document.
 The name of the class is returned and can be used by the plugin.
-See link:#Gerrit_html[`Gerrit.html()`] for an easy way to use
-generated class names.
 
 Classes created with this function should be created once at install
 time and reused throughout the plugin.  Repeatedly creating the same
@@ -732,194 +459,6 @@
 });
 ----
 
-[[Gerrit_delete]]
-=== Gerrit.delete()
-Issues a DELETE REST API request to the Gerrit server. For plugin
-private REST API URLs see link:#self_delete[self.delete()].
-
-.Signature
-[source,javascript]
-----
-Gerrit.delete(url, callback)
-----
-
-* url: URL relative to the Gerrit server. For example to access the
-  link:rest-api-changes.html[changes REST API] use `'/changes/'`.
-
-* callback: JavaScript function to be invoked with the parsed
-  JSON result of the API call. DELETE methods often return
-  `204 No Content`, which is passed as null.
-
-[source,javascript]
-----
-Gerrit.delete(
-  '/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
-  function () {});
-----
-
-[[Gerrit_get]]
-=== Gerrit.get()
-Issues a GET REST API request to the Gerrit server. For plugin
-private REST API URLs see link:#self_get[self.get()].
-
-.Signature
-[source,javascript]
-----
-Gerrit.get(url, callback)
-----
-
-* url: URL relative to the Gerrit server. For example to access the
-  link:rest-api-changes.html[changes REST API] use `'/changes/'`.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
-[source,javascript]
-----
-Gerrit.get('/changes/?q=status:open', function (open) {
-  for (var i = 0; i < open.length; i++) {
-    console.log(open[i].change_id);
-  }
-});
-----
-
-[[Gerrit_getCurrentUser]]
-=== Gerrit.getCurrentUser()
-Returns the currently signed in user's AccountInfo data; empty account
-data if no user is currently signed in.
-
-[[Gerrit_getPluginName]]
-=== Gerrit.getPluginName()
-Returns the name this plugin was installed as by the server
-administrator. The plugin name is required to access REST API
-views installed by the plugin, or to access resources.
-
-Unlike link:#self_getPluginName[`self.getPluginName()`] this method
-must guess the name from the JavaScript call stack. Plugins are
-encouraged to use `self.getPluginName()` whenever possible.
-
-[[Gerrit_go]]
-=== Gerrit.go()
-Updates the web UI to display the screen identified by the supplied
-URL token. The URL token is the text after `#` in the browser URL.
-
-[source,javascript]
-----
-Gerrit.go('/admin/projects/');
-----
-
-If the URL passed matches `http://...`, `https://...`, or `//...`
-the current browser window will navigate to the non-Gerrit URL.
-The user can return to Gerrit with the back button.
-
-[[Gerrit_html]]
-=== Gerrit.html()
-Parses an HTML fragment after performing template replacements.  If
-the HTML has a single root element or node that node is returned,
-otherwise it is wrapped inside a `<div>` and the div is returned.
-
-.Signature
-[source,javascript]
-----
-Gerrit.html(htmlText, options, wantElements);
-----
-
-* htmlText: string of HTML to be parsed.  A new unattached `<div>` is
-  created in the browser's document and the innerHTML property is
-  assigned to the passed string, after performing replacements.  If
-  the div has exactly one child, that child will be returned instead
-  of the div.
-
-* options: optional object reference supplying replacements for any
-  `{name}` references in htmlText.  Navigation through objects is
-  supported permitting `{style.bar}` to be replaced with `"foo"` if
-  options was `{style: {bar: "foo"}}`.  Value replacements are HTML
-  escaped before being inserted into the document fragment.
-
-* wantElements: if options is given and wantElements is also true
-  an object consisting of `{root: parsedElement, elements: {...}}` is
-  returned instead of the parsed element. The elements object contains
-  a property for each element using `id={name}` in htmlText.
-
-.Example
-[source,javascript]
-----
-var style = {bar: Gerrit.css('background: yellow')};
-Gerrit.html(
-  '<span class="{style.bar}">Hello {name}!</span>',
-  {style: style, name: "World"});
-----
-
-Event handlers can be automatically attached to elements referenced
-through an attribute id.  Object navigation is not supported for ids,
-and the parser strips the id attribute before returning the result.
-Handler functions must begin with `on` and be a function to be
-installed on the element.  This approach is useful for onclick and
-other handlers that do not want to create circular references that
-will eventually leak browser memory.
-
-.Example
-[source,javascript]
-----
-var options = {
-  link: {
-    onclick: function(e) { window.close() },
-  },
-};
-Gerrit.html('<a href="javascript:;" id="{link}">Close</a>', options);
-----
-
-When using options to install handlers care must be taken to not
-accidentally include the returned element into the event handler's
-closure.  This is why options is built before calling `Gerrit.html()`
-and not inline as a shown above with "Hello World".
-
-DOM nodes can optionally be returned, allowing handlers to access the
-elements identified by `id={name}` at a later point in time.
-
-.Example
-[source,javascript]
-----
-var w = Gerrit.html(
-    '<div>Name: <input type="text" id="{name}"></div>'
-  + '<div>Age: <input type="text" id="{age}"></div>'
-  + '<button id="{submit}"><div>Save</div></button>',
-  {
-    submit: {
-      onclick: function(s) {
-        var e = w.elements;
-        window.alert(e.name.value + " is " + e.age.value);
-      },
-    },
-  }, true);
-----
-
-To prevent memory leaks `w.root` and `w.elements` should be set to
-null when the elements are no longer necessary.  Screens can use
-link:#screen_onUnload[screen.onUnload()] to define a callback function
-to perform this cleanup:
-
-[source,javascript]
-----
-var w = Gerrit.html(...);
-screen.body.appendElement(w.root);
-screen.onUnload(function() { w.clear() });
-----
-
-[[Gerrit_injectCss]]
-=== Gerrit.injectCss()
-Injects CSS rules into the document by appending onto the end of the
-existing rule list.  CSS rules are global to the entire application
-and must be manually scoped by each plugin.  For an automatic scoping
-alternative see link:#Gerrit_css[`css()`].
-
-[source,javascript]
-----
-Gerrit.injectCss('.myplugin_bg {background: #000}');
-----
-
 [[Gerrit_install]]
 === Gerrit.install()
 Registers a new plugin by invoking the supplied initialization
@@ -932,136 +471,6 @@
 });
 ----
 
-[[Gerrit_post]]
-=== Gerrit.post()
-Issues a POST REST API request to the Gerrit server. For plugin
-private REST API URLs see link:#self_post[self.post()].
-
-.Signature
-[source,javascript]
-----
-Gerrit.post(url, input, callback)
-----
-
-* url: URL relative to the Gerrit server. For example to access the
-  link:rest-api-changes.html[changes REST API] use `'/changes/'`.
-
-* input: JavaScript object to serialize as the request payload.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
-[source,javascript]
-----
-Gerrit.post(
-  '/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
-  {topic: 'tests', message: 'Classify work as for testing.'},
-  function (r) {});
-----
-
-[[Gerrit_put]]
-=== Gerrit.put()
-Issues a PUT REST API request to the Gerrit server. For plugin
-private REST API URLs see link:#self_put[self.put()].
-
-.Signature
-[source,javascript]
-----
-Gerrit.put(url, input, callback)
-----
-
-* url: URL relative to the Gerrit server. For example to access the
-  link:rest-api-changes.html[changes REST API] use `'/changes/'`.
-
-* input: JavaScript object to serialize as the request payload.
-
-* callback: JavaScript function to be invoked with the parsed JSON
-  result of the API call. If the API returns a string the result is
-  a string, otherwise the result is a JavaScript object or array,
-  as described in the relevant REST API documentation.
-
-[source,javascript]
-----
-Gerrit.put(
-  '/changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic',
-  {topic: 'tests', message: 'Classify work as for testing.'},
-  function (r) {});
-----
-
-[[Gerrit_onAction]]
-=== Gerrit.onAction()
-Register a JavaScript callback to be invoked when the user clicks
-on a button associated with a server side `UiAction`.
-
-.Signature
-[source,javascript]
-----
-Gerrit.onAction(type, view_name, callback);
-----
-
-* type: `'change'`, `'edit'`, `'revision'`, `'project'` or `'branch'`
-  indicating what sort of resource the `UiAction` was bound to in the server.
-
-* view_name: string appearing in URLs to name the view. This is the
-  second argument of the `get()`, `post()`, `put()`, and `delete()`
-  binding methods in a `RestApiModule`.
-
-* callback: JavaScript function to invoke when the user clicks. The
-  function will be passed a link:#ActionContext[ActionContext].
-
-[[Gerrit_screen]]
-=== Gerrit.screen()
-Register a JavaScript callback to be invoked when the user navigates
-to an extension screen provided by the plugin. Extension screens are
-usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
-The callback can populate the DOM with the screen's contents.
-
-.Signature
-[source,javascript]
-----
-Gerrit.screen(pattern, callback);
-----
-
-* pattern: URL token pattern to identify the screen. Argument can be
-  either a string (`'index'`) or a RegExp object (`/list\/(.*)/`).
-  If a RegExp is used the matching groups will be available inside of
-  the context as `token_match`.
-
-* callback: JavaScript function to invoke when the user navigates to
-  the screen. The function will be passed link:#ScreenContext[screen context].
-
-[[Gerrit_refresh]]
-=== Gerrit.refresh()
-Redisplays the current web UI view, refreshing all information.
-
-[[Gerrit_refreshMenuBar]]
-=== Gerrit.refreshMenuBar()
-Refreshes Gerrit's menu bar.
-
-[[Gerrit_isSignedIn]]
-=== Gerrit.isSignedIn()
-Checks if user is signed in.
-
-[[Gerrit_url]]
-=== Gerrit.url()
-Returns the URL of the Gerrit Code Review server. If invoked with
-no parameter the URL of the site is returned. If passed a string
-the argument is appended to the site URL.
-
-[source,javascript]
-----
-Gerrit.url();        // "https://gerrit-review.googlesource.com/"
-Gerrit.url('/123');  // "https://gerrit-review.googlesource.com/123"
-----
-
-For a plugin specific version see link:#self_url()[`self.url()`].
-
-[[Gerrit_showError]]
-=== Gerrit.showError(message)
-Displays the given message in the Gerrit ErrorDialog.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index bb1399d..c2bdfbb3 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -3,7 +3,6 @@
 Apache2.0
 
 * fonts:robotofonts
-* js:web-animations-js
 * polymer_externs:polymer_closure
 
 [[Apache2_0_license]]
@@ -477,33 +476,33 @@
 ----
 
 
-[[promise-polyfill]]
-promise-polyfill
+[[shadycss]]
+shadycss
 
-* js:promise-polyfill
+* js:shadycss
 
-[[promise-polyfill_license]]
+[[shadycss_license]]
 ----
-Copyright (c) 2014 Taylor Hakes
-Copyright (c) 2014 Forbes Lindesay
+# License
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+Everything in this repo is BSD style license unless otherwise specified.
 
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+Copyright (c) 2015 The Polymer Authors. All rights reserved.
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+* Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
 
 ----
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 9936aea..1a9a8f6 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -52,6 +52,7 @@
 * commons:pool
 * commons:validator
 * dropwizard:dropwizard-core
+* errorprone:annotations
 * flogger:api
 * fonts:robotofonts
 * guice:guice
@@ -72,8 +73,6 @@
 * jetty:server
 * jetty:servlet
 * jetty:util
-* jgit/org.eclipse.jgit:javaewah
-* js:web-animations-js
 * log:json-smart
 * log:jsonevent-layout
 * log:log4j
@@ -98,10 +97,11 @@
 * guava-retrying
 * html-types
 * j2objc
+* javaewah
 * jsr305
 * mime-util
-* servlet-api-3_1
-* servlet-api-3_1-without-neverlink
+* servlet-api
+* servlet-api-without-neverlink
 * soy
 
 [[Apache2_0_license]]
@@ -2428,9 +2428,9 @@
 [[jgit]]
 jgit
 
-* jgit/org.eclipse.jgit.archive:jgit-archive
-* jgit/org.eclipse.jgit.http.server:jgit-servlet
-* jgit/org.eclipse.jgit:jgit
+* jgit
+* jgit-archive
+* jgit-servlet
 
 [[jgit_license]]
 ----
@@ -3329,37 +3329,6 @@
 ----
 
 
-[[promise-polyfill]]
-promise-polyfill
-
-* js:promise-polyfill
-
-[[promise-polyfill_license]]
-----
-Copyright (c) 2014 Taylor Hakes
-Copyright (c) 2014 Forbes Lindesay
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
 [[protobuf]]
 protobuf
 
@@ -3404,6 +3373,37 @@
 ----
 
 
+[[shadycss]]
+shadycss
+
+* js:shadycss
+
+[[shadycss_license]]
+----
+# License
+
+Everything in this repo is BSD style license unless otherwise specified.
+
+Copyright (c) 2015 The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+* Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+----
+
+
 [[slf4j]]
 slf4j
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index bfebc6a..643bde0 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases]. The steps below install Gerrit 2.15.1:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases]. The steps below install Gerrit 3.1.3:
 
 ....
-wget https://www.gerritcodereview.com/download/gerrit-2.15.1.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
@@ -43,7 +43,8 @@
 From the command line, enter:
 
 ....
-java -jar gerrit*.war init --batch --dev -d ~/gerrit_testsite
+export GERRIT_SITE=~/gerrit_testsite
+java -jar gerrit*.war init --batch --dev -d $GERRIT_SITE
 ....
 
 This command takes two parameters:
@@ -78,7 +79,7 @@
 `localhost`. For example:
 
 ....
-git config --file ~/gerrit_testsite/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
+git config --file $GERRIT_SITE/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
 ....
 
 == Restart the Gerrit service
@@ -87,7 +88,7 @@
 changes to take effect:
 
 ....
-~/gerrit_testsite/bin/gerrit.sh restart
+$GERRIT_SITE/bin/gerrit.sh restart
 ....
 
 == Viewing Gerrit
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index c0e23c5..2545b5d 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -15,10 +15,12 @@
 
 === Actions
 
-* `action/retry_attempt_counts`: Distribution of number of attempts made
-by RetryHelper to execute an action (1 == single attempt, no retry)
+* `action/retry_attempt_count`: Number of retry attempts made
+by RetryHelper to execute an action (0 == single attempt, no retry)
 * `action/retry_timeout_count`: Number of action executions of RetryHelper
 that ultimately timed out
+* `action/auto_retry_count`: Number of automatic retries with tracing
+* `action/failures_on_auto_retry_count`: Number of failures on auto retry
 
 === Pushes
 
@@ -63,6 +65,11 @@
 * `caches/disk_cached`: Disk entries used by persistent cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
 
+=== Change
+
+* `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
+* `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
+
 === HTTP
 
 ==== Jetty
@@ -179,6 +186,10 @@
 * `notedb/stage_update_latency`: Latency for staging updates to NoteDb by table.
 * `notedb/read_latency`: NoteDb read latency by table.
 * `notedb/parse_latency`: NoteDb parse latency by table.
+* `notedb/external_id_cache_load_count`: Total number of times the external ID
+  cache loader was called.
+* `notedb/external_id_partial_read_latency`: Latency for generating a new external ID
+  cache state from a prior state.
 * `notedb/external_id_update_count`: Total number of external ID updates.
 * `notedb/read_all_external_ids_latency`: Latency for reading all
 external ID's from NoteDb.
diff --git a/Documentation/pg-plugin-admin-api.txt b/Documentation/pg-plugin-admin-api.txt
index 084fa2c..1a41778 100644
--- a/Documentation/pg-plugin-admin-api.txt
+++ b/Documentation/pg-plugin-admin-api.txt
@@ -4,14 +4,18 @@
 and provides customization of the admin menu.
 
 == addMenuLink
-`adminApi.addMenuLink(text, url, opt_external, opt_capabilities)`
+`adminApi.addMenuLink(text, url, opt_capability)`
 
 Add a new link to the end of the admin navigation menu.
 
 .Params
 - *text* String text to appear in the link.
 - *url* String of the destination URL for the link.
+- *opt_capability* String of capability required to show this link.
 
 When adding an external link, the URL provided should be a full URL. Otherwise,
 a non-external link should be relative beginning with a slash. For example, to
-create a link to open changes, use the value `/q/status:open`.
\ No newline at end of file
+create a link to open changes, use the value `/q/status:open`.
+
+See more about capability from
+link:rest-api-accounts.html#list-account-capabilities[List Account Capabilities].
\ No newline at end of file
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 8fb5655..d901851 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -360,6 +360,16 @@
 
 Deprecated. Use link:#plugin-settings[`plugin.settings()`] instead.
 
+[[plugin-styles]]
+=== styles
+`plugin.styles()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-styles-api.html[GrStylesApi]
+
 === changeMetadata
 `plugin.changeMetadata()`
 
@@ -372,6 +382,7 @@
 === theme
 `plugin.theme()`
 
+
 Note: TODO
 
 === url
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 3d66dd4..a8b3330 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -134,6 +134,11 @@
 === header-title
 This endpoint wraps the title-text in the application header.
 
+=== confirm-revert-change
+This endpoint is inside the confirm revert dialog. By default it displays a
+generic confirmation message regarding reverting the change. Plugins may add
+content to this message or replace it entirely.
+
 === confirm-submit-change
 This endpoint is inside the confirm submit dialog. By default it displays a
 generic confirmation message regarding submission of the change. Plugins may add
diff --git a/Documentation/pg-plugin-style-object.txt b/Documentation/pg-plugin-style-object.txt
new file mode 100644
index 0000000..cdcfb55
--- /dev/null
+++ b/Documentation/pg-plugin-style-object.txt
@@ -0,0 +1,33 @@
+= Gerrit Code Review - GrStyleObject
+
+Store information about css style properties. You can't create this object
+directly. Instead you should use the link:pg-plugin-styles-api.html#css[css] method.
+This object allows to apply style correctly to elements within different shadow
+subtree.
+
+[[get-class-name]]
+== getClassName
+`styleObject.getClassName(element)`
+
+.Params
+- `element` - an HTMLElement.
+
+.Returns
+- `string` - class name. The class name is valid only within the shadow root of `element`.
+
+Creates a new unique CSS class and injects it into the appropriate place
+in DOM (it can be document or shadow root for element). This class can be later
+added to the element or to any other element in the same shadow root. It is guarantee,
+that method adds CSS class only once for each shadow root.
+
+== apply
+`styleObject.apply(element)`
+
+.Params
+- `element` - element to apply style.
+
+Create a new unique CSS class (see link:#get-class-name[getClassName]) and
+adds class to the element.
+
+
+
diff --git a/Documentation/pg-plugin-styles-api.txt b/Documentation/pg-plugin-styles-api.txt
new file mode 100644
index 0000000..a829325
--- /dev/null
+++ b/Documentation/pg-plugin-styles-api.txt
@@ -0,0 +1,29 @@
+= Gerrit Code Review - Plugin styles API
+
+This API is provided by link:pg-plugin-dev.html#plugin-styles[plugin.styles()]
+and provides a way to apply dynamically created styles to elements in a
+document.
+
+[[css]]
+== css
+`styles.css(rulesStr)`
+
+.Params
+- `*string* rulesStr` string with CSS styling declarations.
+
+Example:
+----
+const styleObject = plugin.styles().css('background: black; color: white;');
+...
+const className = styleObject.getClassName(element)
+...
+element.classList.add(className);
+...
+styleObject.apply(someOtherElement);
+----
+
+.Returns
+- Instance of link:pg-plugin-style-object.html[GrStyleObject].
+
+
+
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
index 301da51..2453bad 100644
--- a/Documentation/pg-plugin-styling.txt
+++ b/Documentation/pg-plugin-styling.txt
@@ -23,13 +23,15 @@
 
 ``` html
   <dom-module id="some-style">
-    <style>
-      :root {
-        --css-mixin-name: {
-          property: value;
+    <template>
+      <style>
+        html {
+          --css-mixin-name: {
+            property: value;
+          }
         }
-      }
-    </style>
+      </style>
+    </template>
   </dom-module>
 ```
 
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index ad07cfa..7345d06 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -11,7 +11,7 @@
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
   [--console-log]
-  [--slave]
+  [--replica]
   [--headless]
   [--init]
   [-s]
@@ -32,15 +32,15 @@
 --enable-httpd::
 --disable-httpd::
 	Enable (or disable) the internal HTTP daemon, answering
-	web requests. Enabled by default when --slave is not used.
+	web requests. Enabled by default when --replica is not used.
 
 --enable-sshd::
 --disable-sshd::
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
---slave::
-	Run in slave mode, permitting only read operations
+--replica::
+	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
     link:cmd-receive-pack.html[receive-pack] (creates new changes
     or updates existing ones) or link:cmd-review.html[review]
@@ -80,9 +80,9 @@
 external log cleaning service to clean up the prior logs.
 
 == KNOWN ISSUES
-Slave daemon caches can quickly become out of date when modifications
-are made on the master.  The following configuration is suggested in
-a slave to reduce the maxAge for each cache entry, so that changes
+Replica daemon caches can quickly become out of date when modifications
+are made on the primary node.  The following configuration is suggested in
+a replica to reduce the maxAge for each cache entry, so that changes
 are recognized in a reasonable period of time:
 
 ----
@@ -106,7 +106,7 @@
   maxAge = 5 min
 ----
 
-Automatic cache coherency between master and slave systems is
+Automatic cache coherency between primary and replica systems is
 planned to be implemented in a future version.
 
 GERRIT
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 9a23a27..f291920 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -74,6 +74,9 @@
 link:pgm-prolog-shell.html[prolog-shell] program which opens an interactive
 Prolog interpreter shell.
 
+For batch or unit tests, see the examples in Gerrit source directory
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples].
+
 [NOTE]
 The interactive shell is just a prolog shell, it does not load
 a gerrit server environment and thus is not intended for
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 1d4f6fb..6f0d828 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -58,8 +58,8 @@
 
 [[details]]
 --
-* `DETAILS`: Includes full name, preferred email, username and avatars
-for each account.
+* `DETAILS`: Includes full name, preferred email, username, avatars,
+status and state for each account.
 --
 
 [[all-emails]]
@@ -1254,13 +1254,10 @@
   )]}'
   {
     "changes_per_page": 25,
-    "show_site_header": true,
-    "use_flash_clipboard": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
     "size_bar_in_change_table": true,
-    "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
@@ -1309,13 +1306,10 @@
 
   {
     "changes_per_page": 50,
-    "show_site_header": true,
-    "use_flash_clipboard": true,
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
-    "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -1359,13 +1353,10 @@
   )]}'
   {
     "changes_per_page": 50,
-    "show_site_header": true,
-    "use_flash_clipboard": true,
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
-    "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
@@ -2201,7 +2192,7 @@
 account.
 
 `AccountDetailInfo` has the same fields as link:#account-info[
-AccountInfo]. In addition `AccountDetailInfo` has the following fields:
+AccountInfo]. In addition `AccountDetailInfo` has the following field:
 
 [options="header",cols="1,^1,5"]
 |=================================
@@ -2209,8 +2200,6 @@
 |`registered_on`     ||
 The link:rest-api.html#timestamp[timestamp] of when the account was
 registered.
-|`inactive`          |not set if `false`|
-Whether the account is inactive.
 |=================================
 
 [[account-external-id-info]]
@@ -2261,9 +2250,14 @@
 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.
+|`inactive`        |not set if `false`|
+Whether the account is inactive.
 |===============================
 
 [[account-input]]
@@ -2309,6 +2303,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
@@ -2705,10 +2712,6 @@
 |`changes_per_page`             ||
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
-|`show_site_header`             |not set if `false`|
-Whether the site header should be shown.
-|`use_flash_clipboard`          |not set if `false`|
-Whether to use the flash clipboard widget.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
@@ -2731,9 +2734,6 @@
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |not set if `false`|
 Whether to show change number in the change table.
-|`review_category_strategy`     ||
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |not set if `false`|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |not set if `false`|
@@ -2774,10 +2774,6 @@
 |`changes_per_page`             |optional|
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
-|`show_site_header`             |optional|
-Whether the site header should be shown.
-|`use_flash_clipboard`          |optional|
-Whether to use the flash clipboard widget.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
@@ -2798,9 +2794,6 @@
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |optional|
 Whether to show change number in the change table.
-|`review_category_strategy`     |optional|
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |optional|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |optional|
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index cd1ea79..91c1cff 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -318,6 +318,11 @@
 A change's mergeability can be requested separately by calling the
 link:#get-mergeable[get-mergeable] endpoint.
 --
+[[skip_diffstat]]
+--
+* `SKIP_DIFFSTAT`: skip the 'insertions' and 'deletions' field in link:#change-info[ChangeInfo].
+ For large trees, their computation may be expensive.
+--
 
 [[submittable]]
 --
@@ -844,8 +849,10 @@
 Creates a new patch set with a new commit message.
 
 The new commit message must be provided in the request body inside a
-link:#commit-message-input[CommitMessageInput] entity and contain the change ID footer if
-link:project-configuration.html#require-change-id[Require Change-Id] was specified.
+link:#commit-message-input[CommitMessageInput] entity. If a Change-Id
+footer is specified, it must match the current Change-Id footer. If
+the Change-Id footer is absent, the current Change-Id is added to the
+message.
 
 .Request
 ----
@@ -2634,6 +2641,14 @@
   HTTP/1.1 204 No Content
 ----
 
+When the change edit is a no-op, for example when providing the same file
+content that the file already has, '409 no changes were made' is returned.
+
+.Response
+----
+  HTTP/1.1 409 no changes were made
+----
+
 [[post-edit]]
 === Restore file content or rename files in Change Edit
 --
@@ -2980,7 +2995,20 @@
 Suggest the reviewers for a given query `q` and result limit `n`. If result
 limit is not passed, then the default 10 is used.
 
-Groups can be excluded from the results by specifying 'e=f'.
+This REST endpoint only suggests accounts that
+
+* are active
+* can see the change
+* are visible to the calling user
+* are not already reviewer on the change
+* don't own the change
+
+Groups can be excluded from the results by specifying the 'exclude-groups'
+request parameter:
+
+--
+'GET /changes/link:#change-id[\{change-id\}]/suggest_reviewers?q=J&n=5&exclude-groups'
+--
 
 As result a list of link:#suggested-reviewer-info[SuggestedReviewerInfo] entries is returned.
 
@@ -3015,6 +3043,14 @@
   ]
 ----
 
+To suggest CCs `reviewer-state=CC` can be specified as additional URL
+parameter. This includes existing reviewers in the result, but excludes
+existing CCs.
+
+--
+'GET /changes/link:#change-id[\{change-id\}]/suggest_reviewers?q=J&reviewer-state=CC'
+--
+
 [[get-reviewer]]
 === Get Reviewer
 --
@@ -3060,6 +3096,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
@@ -3103,6 +3145,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
@@ -4338,8 +4386,10 @@
     R = label('Any-Label-Name', reject(_)).
 ----
 
-The response is a list of link:#submit-record[SubmitRecord] entries
-describing the permutations that satisfy the tested submit rule.
+The response is a link:#submit-record[SubmitRecord] describing the
+permutations that satisfy the tested submit rule.
+
+If the submit rule was a no-op, the response is "`204 No Content`".
 
 .Response
 ----
@@ -4348,14 +4398,12 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  [
-    {
-      "status": "NOT_READY",
-      "reject": {
-        "Any-Label-Name": {}
-      }
+  {
+    "status": "NOT_READY",
+    "reject": {
+      "Any-Label-Name": {}
     }
-  ]
+  }
 ----
 
 When testing with the `curl` command line client the
@@ -4912,7 +4960,8 @@
 For merge commits only, the integer-valued request parameter `parent`
 changes the response to return a map of the files which are different
 in this commit compared to the given parent commit. The value is the
-1-based index of the parent's position in the commit object. If not
+1-based index of the parent's position in the commit object,
+with the first parent always belonging to the target branch. If not
 specified, the response contains a map of the files different in the
 auto merge result.
 
@@ -5974,7 +6023,9 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name         ||Description
-|`message`          ||Commit message for the cherry-pick change
+|`message`          |optional|
+Commit message for the cherry-pick change. If not set, the commit message of
+the cherry-picked commit is used.
 |`destination`      ||Destination branch
 |`base`             |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
@@ -6254,10 +6305,12 @@
 |`a`            |optional|Content only in the file on side A (deleted in B).
 |`b`            |optional|Content only in the file on side B (added in B).
 |`ab`           |optional|Content in the file on both sides (unchanged).
-|`edit_a`       |only present during a replace, i.e. both `a` and `b` are present|
+|`edit_a`       |only present when the `intraline` parameter is set and the
+DiffContent is a replace, i.e. both `a` and `b` are present|
 Text sections deleted from side A as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
-|`edit_b`       |only present during a replace, i.e. both `a` and `b` are present|
+|`edit_b`       |only present when the `intraline` parameter is set and the
+DiffContent is a replace, i.e. both `a` and `b` are present|
 Text sections inserted in side B as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
 |`due_to_rebase`|not set if `false`|Indicates whether this entry was introduced by a
@@ -6320,11 +6373,12 @@
 The `DiffIntralineInfo` entity contains information about intraline edits in a
 file.
 
-The information consists of a list of `<skip length, mark length>` pairs, where
+The information consists of a list of `<skip length, edit length>` pairs, where
 the skip length is the number of characters between the end of the previous edit
-and the start of this edit, and the mark length is the number of edited characters
+and the start of this edit, and the edit length is the number of edited characters
 following the skip. The start of the edits is from the beginning of the related
-diff content lines.
+diff content lines. If the list is empty, the entire DiffContent should be considered
+as unedited.
 
 Note that the implied newline character at the end of each line is included in
 the length calculation, and thus it is possible for the edits to span newlines.
@@ -6871,6 +6925,9 @@
 |`notify_details`|optional|
 Additional information about whom to notify about the revert as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
+|`topic`         |optional|
+Name of the topic for the revert change. If not set, the default is the topic
+of the change being reverted.
 |=============================
 
 [[review-info]]
@@ -7079,7 +7136,7 @@
 |`reviewed`     |optional|
 Indicates whether the caller is authenticated and has commented on the
 current revision. Only set if link:#reviewed[REVIEWED] option is requested.
-|`messageWithFooter` |optional|
+|`commit_with_footers` |optional|
 If the link:#commit-footers[COMMIT_FOOTERS] option is requested and
 this is the current patch set, contains the full commit message with
 Gerrit-specific commit footers, as if this revision were submitted
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 9359112..063e54d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1054,14 +1054,11 @@
   )]}'
   {
     "changes_per_page": 25,
-    "show_site_header": true,
-    "use_flash_clipboard": true,
     "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
     "size_bar_in_change_table": true,
-    "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "my": [
@@ -1133,14 +1130,11 @@
   )]}'
   {
     "changes_per_page": 50,
-    "show_site_header": true,
-    "use_flash_clipboard": true,
     "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
     "size_bar_in_change_table": true,
-    "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "my": [
@@ -1567,11 +1561,15 @@
 |`update_delay`       ||
 link:config-gerrit.html#change.updateDelay[How often in seconds the web
 interface should poll for updates to the currently open change].
-|`submit_whole_topic` ||
+|`submit_whole_topic` |not set if `false`|
 link:config-gerrit.html#change.submitWholeTopic[A configuration if
 the whole topic is submitted].
 |`disable_private_changes` |not set if `false`|
 Returns true if private changes are disabled.
+|`exclude_mergeable_in_change_info` |not set if `false`|
+Value of the link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[
+configuration parameter] that controls whether the mergeability bit in
+link:rest-api-changes.html#change-info[ChangeInfo] will never be set.
 |=============================
 
 [[check-account-external-ids-input]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 85f7329..c4ba973 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -508,8 +508,9 @@
   }
 ----
 
-If the group creation fails because the name is already in use the
-response is "`409 Conflict`".
+If the group creation fails because the name is already in use, or the
+UUID was specified and the UUID is already in use, the response is
+"`409 Conflict`".
 
 [[get-group-detail]]
 === Get Group Detail
@@ -1596,6 +1597,7 @@
 |Field Name      ||Description
 |`name`          |optional|The name of the group (not encoded). +
 If set, must match the group name in the URL.
+|`uuid`          |optional|The UUID of the group.
 |`description`   |optional|The description of the group.
 |`visible_to_all`|optional|
 Whether the group is visible to all registered users. +
diff --git a/Documentation/rest-api-plugins.txt b/Documentation/rest-api-plugins.txt
index 255704a..77b180e 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -390,6 +390,24 @@
   }
 ----
 
+Disabling of a link:config-gerrit.html#plugins.mandatory[mandatory plugin]
+is rejected:
+
+.Request
+----
+  DELETE /plugins/replication HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 405 Method Not Allowed
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  Plugin replication is mandatory
+----
+
 [[reload-plugin]]
 === Reload Plugin
 --
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 76151b4..c1349aa 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2787,17 +2787,18 @@
   }
 ----
 
-[[set-dashboard]]
-=== Set Dashboard
+[[create-dashboard]]
+=== Create Dashboard
 --
 'PUT /projects/link:#project-name[\{project-name\}]/dashboards/link:#dashboard-id[\{dashboard-id\}]'
 --
 
-Updates/Creates a project dashboard.
+Creates a project dashboard, if a project dashboard with the given
+dashboard ID doesn't exist yet.
 
 Currently only supported for the `default` dashboard.
 
-The creation/update information for the dashboard must be provided in
+The creation information for the dashboard must be provided in
 the request body as a link:#dashboard-input[DashboardInput] entity.
 
 .Request
@@ -2811,7 +2812,63 @@
   }
 ----
 
-As response the new/updated dashboard is returned as a
+As response the new dashboard is returned as a link:#dashboard-info[
+DashboardInfo] entity.
+
+.Response
+----
+  HTTP/1.1 201 Created
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "main:closed",
+    "ref": "main",
+    "path": "closed",
+    "description": "Merged and abandoned changes in last 7 weeks",
+    "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
+    "is_default": true,
+    "title": "Closed changes",
+    "sections": [
+      {
+        "name": "Merged",
+        "query": "status:merged age:7w"
+      },
+      {
+        "name": "Abandoned",
+        "query": "status:abandoned age:7w"
+      }
+    ]
+  }
+----
+
+[[update-dashboard]]
+=== Update Dashboard
+--
+'PUT /projects/link:#project-name[\{project-name\}]/dashboards/link:#dashboard-id[\{dashboard-id\}]'
+--
+
+Updates a project dashboard, if a project dashboard with the given
+dashboard ID already exists.
+
+Currently only supported for the `default` dashboard.
+
+The update information for the dashboard must be provided in
+the request body as a link:#dashboard-input[DashboardInput] entity.
+
+.Request
+----
+  PUT /projects/work%2Fmy-project/dashboards/default HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "main:closed",
+    "commit_message": "Update the default dashboard"
+  }
+----
+
+As response the updated dashboard is returned as a
 link:#dashboard-info[DashboardInfo] entity.
 
 .Response
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index 28f01e9..05765ee 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -72,7 +72,7 @@
 
 To add a file to the change:
 
-. In the top left corner of the change, click Edit.
+. In the top right corner of the change, click Edit.
 . Next to Files, click Open:
 
 +
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
index 8430e97..b26f4c1 100644
--- a/Documentation/user-request-tracing.txt
+++ b/Documentation/user-request-tracing.txt
@@ -1,5 +1,8 @@
 = Request Tracing
 
+[[on-demand]]
+== On-demand Request Tracing
+
 Gerrit supports on-demand tracing of single requests that results in
 additional logs with debug information that are written to the
 `error_log`. The logs that correspond to a traced request are
@@ -19,17 +22,24 @@
   `--trace` option. More information about this can be found in
   the link:cmd-index.html#trace[Trace] section of the
   link:cmd-index.html[SSH command documentation].
-* Git: For Git pushes tracing can be enabled by setting the
-  `trace` push option, the trace ID is returned in the command output.
-  More information about this can be found in
-  the link:user-upload.html#trace[Trace] section of the
-  link:user-upload.html[upload documentation]. Tracing for Git requests
-  other than Git push is not supported.
+* Git Push (requires usage of git protocol v2): For Git pushes tracing
+  can be enabled by setting the `trace` push option, the trace ID is
+  returned in the command output. More information about this can be
+  found in the link:user-upload.html#trace[Trace] section of the
+  link:user-upload.html[upload documentation].
+* Git Clone/Fetch/Ls-Remote (requires usage of git protocol v2): For
+  Git clone/fetch/ls-remote tracing can be enabled by setting the
+  `trace` server option. Use '-o trace=<TRACE-ID>' for `git fetch` and
+  `git ls-remote`, and '--server-option trace=<TRACE-ID>' for
+  `git clone`. If the `trace` server option is set without a value
+  (without trace ID) a trace ID is generated but the generated trace ID
+  is not returned to the client (hence a trace ID should always be
+  set).
 
 When request tracing is enabled it is possible to provide an ID that
 should be used as trace ID. If a trace ID is not provided a trace ID is
 automatically generated. The trace ID must be provided to the support
-team so that they can find the trace.
+team (administrator of the server) so that they can find the trace.
 
 When doing traces it is recommended to specify the ID of the issue
 that is being investigated as trace ID so that the traces of the issue
@@ -41,6 +51,71 @@
 debugging. In particular bots should never enable tracing for all their
 requests by default.
 
+[[auto-retry]]
+== Automatic Request Tracing
+
+Gerrit can be link:config-gerrit.html#retry.retryWithTraceOnFailure[
+configured] to automatically retry requests on non-recoverable failures
+with tracing enabled. This allows to automatically captures traces of
+these failures for further analysis by the Gerrit administrators.
+
+The auto-retry on failure behaves the same way as if the calling user
+would retry the failed operation with tracing enabled.
+
+It is expected that the auto-retry fails with the same exception that
+triggered the auto-retry, however this is not guaranteed:
+
+* Not all Gerrit operations are fully atomic and it can happen that
+  some parts of the operation have been successfully performed before
+  the failure happened. In this case the auto-retry may fail with a
+  different exception.
+* Some exceptions may mistakenly be considered as non-recoverable and
+  the auto-retry actually succeeds.
+
+[[auto-retry-succeeded]]
+If an auto-retry succeeds you may consider filing this as
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=GoogleSource+Issue[
+Gerrit issue] so that the Gerrit developers can fix this and treat this
+exception as recoverable.
+
+The trace IDs for auto-retries are generated and start with
+`retry-on-failure-`. For REST requests they are returned to the client
+as `X-Gerrit-Trace` header.
+
+The best way to search for auto-retries in logs is to do a grep by
+`AutoRetry`. For each auto-retry that happened this should match 1 or 2
+log entries:
+
+* one `ERROR` log entry with the exception that triggered the
+  auto-retry
+* one `FINE` log entry with the exception that happened on auto-retry
+  (if this log entry is not present the operation succeeded on
+  auto-retry)
+
+To inspect single auto-retry occurrences in detail you can do a
+link:#find-trace[grep by the trace ID]. The trace ID is part of the log
+entries which have been found by the previous grep (watch out for
+something like: `retry-on-failure-1534166888910-3985dfba`).
+
+[TIP]
+Auto-retrying on failures is only supported by some of the REST
+endpoints (change REST endpoints that perform updates).
+
+[[auto-retry-metrics]]
+=== Metrics
+
+If auto-retry is link:config-gerrit.html#retry.retryWithTraceOnFailure[
+enabled] the following metrics are reported:
+
+* `action/auto_retry_count`: Number of automatic retries with tracing
+* `action/failures_on_auto_retry_count`: Number of failures on auto retry
+
+By comparing the values of these counters one can see how often the
+auto-retry succeeds. As explained link:#auto-retry-succeeded[above] if
+auto-retries succeed that's an issue with Gerrit that you may want to
+report.
+
+[[find-trace]]
 == Find log entries for a trace ID
 
 If tracing is enabled all log messages that correspond to the traced
@@ -55,6 +130,9 @@
 By doing a grep with the trace ID over the error log the log entries
 that correspond to the request can be found.
 
+[TIP]
+Usually only server administrators have access to the logs.
+
 == Which information is captured in a trace?
 
 * request details
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index cafd5ca..55a9ab7 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -53,7 +53,7 @@
 +
 Amount of time that has expired since the change was last updated
 with a review comment or new patch set.  The age must be specified
-to include a unit suffix, for example `age:2d`:
+to include a unit suffix, for example `-age:2d`:
 +
 * s, sec, second, seconds
 * m, min, minute, minutes
@@ -63,6 +63,10 @@
 * mon, month, months (`1 month` is treated as `30 days`)
 * y, year, years (`1 year` is treated as `365 days`)
 
+`age` can be used both forward and backward looking: `age:2d`
+means 'everything older than 2 days' while `-age:2d` means
+'everything with an age of at most 2 days'.
+
 [[assignee]]
 assignee:'USER'::
 +
@@ -325,7 +329,7 @@
 regular expression. The link:http://www.brics.dk/automaton/[dk.brics.automaton
 library] is used for evaluation of such patterns.
 
-[[footer]]
+[[footer-operator]]
 footer:'FOOTER'::
 +
 Matches any change that has 'FOOTER' as footer in the commit message of the
@@ -407,7 +411,7 @@
 True on any change where the current user is in CC.
 Same as `cc:self`.
 
-is:open, is:pending::
+is:open, is:pending, is:new::
 +
 True if the change is open.
 
@@ -459,7 +463,7 @@
 True if the change is Work In Progress.
 
 [[status]]
-status:open, status:pending::
+status:open, status:pending, status:new::
 +
 True if the change state is 'review in progress'.
 
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 56602e2..6cf5587 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -465,74 +465,8 @@
 amending the message and copying the line from the change's page
 on the web, and then using `git push` as described above.
 
-If Change-Id lines are not available, then the user must use the
-manual mapping technique described below.
-
 For more about Change-Ids, see link:user-changeid.html[Change-Id Lines].
 
-[[manual_replacement_mapping]]
-==== Manual Replacement Mapping
-
-[NOTE]
---
-The remainder of this section describes a manual method of replacing
-changes by matching each commit name to an existing change number.
-End-users should instead prefer to use Change-Id lines in their
-commit messages, as the process is then fully automated by Gerrit
-during normal uploads.
-
-See above for the preferred technique of replacing changes.
-
-Pushing directly to `refs/changes/` is deprecated. If you see the error
-message 'upload to refs/changes not allowed', it means that pushing directly
-to `refs/changes` is disabled on the Gerrit server and the below section does
-not apply to you.
---
-
-To add an additional patch set to a change, replacing it with an
-updated version of the same logical modification, send the new
-commit to the change's ref.  For example, to add the commit whose
-SHA-1 starts with `c0ffee` as a new patch set for change number
-`1979`, use the push refspec `c0ffee:refs/changes/1979` as below:
-
-----
-  git push ssh://sshusername@hostname:29418/projectname c0ffee:refs/changes/1979
-----
-
-This form can be combined together with `refs/for/'branchname'`
-(above) to simultaneously create new changes and replace changes
-during one network transaction.
-
-For example, consider the following sequence of events:
-
-----
-  $ git commit -m A                    ; # create 3 commits
-  $ git commit -m B
-  $ git commit -m C
-
-  $ git push ... HEAD:refs/for/master  ; # upload for review
-  ... A is 1500 ...
-  ... B is 1501 ...
-  ... C is 1502 ...
-
-  $ git rebase -i HEAD~3               ; # edit "A", insert D before B
-                                       ; # now series is A'-D-B'-C'
-  $ git push ...
-      HEAD:refs/for/master
-      HEAD~3:refs/changes/1500
-      HEAD~1:refs/changes/1501
-      HEAD~0:refs/changes/1502         ; # upload replacements
-----
-
-At the final step during the push Gerrit will attach A' as a new
-patch set on change 1500; B' as a new patch set on change 1501; C'
-as a new patch set on 1502; and D will be created as a new change.
-
-Ensuring D is created as a new change requires passing the refspec
-`HEAD:refs/for/branchname`, otherwise Gerrit will ignore D and
-won't do anything with it.  For this reason it is a good idea to
-always include the create change refspec when uploading replacements.
-
 
 [[bypass_review]]
 === Bypass Review
@@ -716,10 +650,6 @@
 amending the message and copying the line from the change's page
 on the web.
 
-If Change-Id lines are not available, then the user must use the much
-more <<manual_replacement_mapping,manual mapping technique>> offered
-by using `git push` to a specific `refs/changes/change#` reference.
-
 For more about Change-Ids, see link:user-changeid.html[Change-Id Lines].
 
 
diff --git a/WORKSPACE b/WORKSPACE
index 75cd023..ef43fc7 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -92,6 +92,12 @@
     importpath = "github.com/howeyc/fsnotify",
 )
 
+# JGit external repository consumed from git submodule
+local_repository(
+    name = "jgit",
+    path = "modules/jgit",
+)
+
 ANTLR_VERS = "3.5.2"
 
 maven_jar(
@@ -156,14 +162,23 @@
 )
 
 maven_jar(
-    name = "servlet-api-3_1",
+    name = "servlet-api",
     artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
     sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
 )
 
-load("//lib/jgit:jgit.bzl", "jgit_repos")
+# JGit's transitive dependencies
+maven_jar(
+    name = "hamcrest-library",
+    artifact = "org.hamcrest:hamcrest-library:1.3",
+    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
+)
 
-jgit_repos()
+maven_jar(
+    name = "jzlib",
+    artifact = "com.jcraft:jzlib:1.1.1",
+    sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
+)
 
 maven_jar(
     name = "javaewah",
@@ -172,6 +187,12 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
+maven_jar(
+    name = "error-prone-annotations",
+    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
+    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
+)
+
 FLOGGER_VERS = "0.4"
 
 maven_jar(
@@ -311,8 +332,8 @@
 # When upgrading commons-compress, also upgrade tukaani-xz
 maven_jar(
     name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.15",
-    sha1 = "b686cd04abaef1ea7bc5e143c080563668eec17e",
+    artifact = "org.apache.commons:commons-compress:1.18",
+    sha1 = "1191f9f2bc0c47a8cce69193feb1ff0a8bcb37d5",
 )
 
 maven_jar(
@@ -619,18 +640,18 @@
     sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
 )
 
-AUTO_VALUE_VERSION = "1.6.5"
+AUTO_VALUE_VERSION = "1.7"
 
 maven_jar(
     name = "auto-value",
     artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "816872c85048f36a67a276ef7a49cc2e4595711c",
+    sha1 = "fe8387764ed19460eda4f106849c664f51c07121",
 )
 
 maven_jar(
     name = "auto-value-annotations",
     artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "c3dad10377f0e2242c9a4b88e9704eaf79103679",
+    sha1 = "5be124948ebdc7807df68207f35a0f23ce427f29",
 )
 
 declare_nongoogle_deps()
@@ -722,7 +743,7 @@
     sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
-GITILES_VERS = "0.2-12"
+GITILES_VERS = "0.3-7"
 
 GITILES_REPO = GERRIT
 
@@ -731,14 +752,14 @@
     artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
     attach_source = False,
     repository = GITILES_REPO,
-    sha1 = "e175e4366d83f20378905ca58a505ba8adac291d",
+    sha1 = "af6212a62363906c63d367f8276ae1645f83bf93",
 )
 
 maven_jar(
     name = "gitiles-servlet",
     artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
     repository = GITILES_REPO,
-    sha1 = "53f654f79ec65b9af7fbe645c99bf7888cd1ac48",
+    sha1 = "6a53f722f8572a2f1bcb7d86e5692168844bab76",
 )
 
 # prettify must match the version used in Gitiles
@@ -751,8 +772,8 @@
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-03-11",
-    sha1 = "119ac4b3eb0e2c638526ca99374013965c727097",
+    artifact = "com.google.template:soy:2019-10-08",
+    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
 )
 
 maven_jar(
@@ -768,24 +789,24 @@
 )
 
 # When updating Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.60"
+BC_VERS = "1.61"
 
 maven_jar(
     name = "bcprov",
     artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
-    sha1 = "bd47ad3bd14b8e82595c7adaa143501e60842a84",
+    sha1 = "00df4b474e71be02c1349c3292d98886f888d1f7",
 )
 
 maven_jar(
     name = "bcpg",
     artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-    sha1 = "13c7a199c484127daad298996e95818478431a2c",
+    sha1 = "422656435514ab8a28752b117d5d2646660a0ace",
 )
 
 maven_jar(
     name = "bcpkix",
     artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-    sha1 = "d0c46320fbc07be3a24eb13a56cee4e3d38e0c75",
+    sha1 = "89bb3aa5b98b48e584eee2a7401b7682a46779b4",
 )
 
 maven_jar(
@@ -796,7 +817,6 @@
 
 # Note that all of the following org.apache.httpcomponents have newer versions,
 # but 4.4.1 is the only version that is available for all of them.
-# TODO: Check what combination of new versions are compatible.
 HTTPCOMP_VERS = "4.4.1"
 
 maven_jar(
@@ -837,30 +857,30 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "0.44"
+TRUTH_VERS = "1.0"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "11eff954c0c14da7d43276d7b3bcf71463105368",
+    sha1 = "998e5fb3fa31df716574b4c9e8d374855e800451",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "2081a0721d3101e1cf559f013e59c6129b4b10b0",
+    sha1 = "d85fbc1daf0510821f552f2aa71d9605e97aa438",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "64f47e4e3f79b0a582573098b9c3c6b73599f7c6",
+    sha1 = "7a279c50a0f93da15533cef4993b45606cf67d72",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "c03fbc16087d8cb3bf0f3265a04566d4beb88a6d",
+    sha1 = "8c0c2ea61750f02d0d5ce9c653106b6a5dc82d12",
 )
 
 maven_jar(
@@ -869,13 +889,6 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-# When bumping the easymock version number, make sure to also move powermock to a compatible version
-maven_jar(
-    name = "easymock",
-    artifact = "org.easymock:easymock:3.1",
-    sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e",
-)
-
 JETTY_VERS = "9.4.27.v20200227"
 
 maven_jar(
@@ -945,6 +958,12 @@
 )
 
 maven_jar(
+    name = "javax-annotation",
+    artifact = "javax.annotation:javax.annotation-api:1.3.2",
+    sha1 = "934c04d3cfef185a8008e7bf34331b79730a9d43",
+)
+
+maven_jar(
     name = "mockito",
     artifact = "org.mockito:mockito-core:2.24.0",
     sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
@@ -953,13 +972,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",
 )
@@ -994,8 +1013,8 @@
 bower_archive(
     name = "iron-autogrow-textarea",
     package = "polymerelements/iron-autogrow-textarea",
-    sha1 = "68f0ece9b1e56ac26f8ce31d9938c504f6951bca",
-    version = "2.1.0",
+    sha1 = "2f04c7e2a72d462de36093ab2b4889db20f699f6",
+    version = "2.2.0",
 )
 
 bower_archive(
@@ -1015,64 +1034,64 @@
 bower_archive(
     name = "iron-dropdown",
     package = "polymerelements/iron-dropdown",
-    sha1 = "ac96fe31cdf203a63426fa75131b43c98c0597d3",
-    version = "1.5.5",
+    sha1 = "3902ba164552b1bfc59e6fa692efa4a1fd8dd4ea",
+    version = "2.2.1",
 )
 
 bower_archive(
     name = "iron-input",
     package = "polymerelements/iron-input",
-    sha1 = "9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac",
-    version = "1.0.10",
+    sha1 = "f79952ff4f6f103c0a2cbd3dacf25935257ff392",
+    version = "2.1.3",
 )
 
 bower_archive(
     name = "iron-overlay-behavior",
     package = "polymerelements/iron-overlay-behavior",
-    sha1 = "74cda9d7bf98e7a5e5004bc7ebdb6d208d49e11e",
-    version = "2.0.0",
+    sha1 = "c2d2eac1b162420d9475ade2f16d5db8959b93fc",
+    version = "2.3.4",
 )
 
 bower_archive(
     name = "iron-selector",
     package = "polymerelements/iron-selector",
-    sha1 = "e0ee46c28523bf17730318c3b481a8ed4331c3b2",
-    version = "2.0.0",
+    sha1 = "3f3fcb55f6bd606ea493f99eab9daae21f7a6139",
+    version = "2.1.0",
 )
 
 bower_archive(
     name = "paper-button",
     package = "polymerelements/paper-button",
-    sha1 = "3b01774f58a8085d3c903fc5a32944b26ab7be72",
-    version = "2.0.0",
+    sha1 = "bcb783d74e1177c1d0836340e7c0280699d1438c",
+    version = "2.1.3",
 )
 
 bower_archive(
     name = "paper-input",
     package = "polymerelements/paper-input",
-    sha1 = "6c934805e80ab201e143406edc73ea0ef35abf80",
-    version = "1.1.18",
+    sha1 = "c1a81a4173d22e72e8ab609eb3715a75273396b3",
+    version = "2.2.3",
 )
 
 bower_archive(
     name = "paper-tabs",
     package = "polymerelements/paper-tabs",
-    sha1 = "b6dd2fbd7ee887534334057a29eb545b940fc5cf",
-    version = "2.0.0",
+    sha1 = "589b8e6efa0f171c93233137c8ea013dcea0ffc7",
+    version = "2.1.1",
 )
 
 bower_archive(
     name = "iron-icon",
     package = "polymerelements/iron-icon",
-    sha1 = "7da49a0d33cd56017740e0dbcf41d2b71532023f",
-    version = "2.0.0",
+    sha1 = "d21e7d4f1bdc6de881390f888e28d53155eeb551",
+    version = "2.1.0",
 )
 
 bower_archive(
     name = "iron-iconset-svg",
     package = "polymerelements/iron-iconset-svg",
-    sha1 = "4d0c406239cad2ff2975c6dd95fa189de0fe6b50",
-    version = "2.1.0",
+    sha1 = "07c0ce02ce6479856758893416a3709009db7f22",
+    version = "2.2.1",
 )
 
 bower_archive(
@@ -1085,36 +1104,36 @@
 bower_archive(
     name = "page",
     package = "visionmedia/page.js",
-    sha1 = "51a05428dd4f68fae1df5f12d0e2b61ba67f7757",
-    version = "1.7.1",
+    sha1 = "4a31889cd75cc5e7f68a4c7f256eecaf27102eee",
+    version = "1.11.4",
 )
 
 bower_archive(
     name = "paper-item",
     package = "polymerelements/paper-item",
-    sha1 = "803273ceb9ffebec8ecc9373ea638af4cd34af58",
-    version = "1.1.4",
+    sha1 = "c3bad022cf182d2bf1c8a44374c7fcb1409afbfa",
+    version = "2.1.1",
 )
 
 bower_archive(
     name = "paper-listbox",
     package = "polymerelements/paper-listbox",
-    sha1 = "ccc1a90ab0a96878c7bf7c9c4cfe47c85b09c8e3",
-    version = "2.0.0",
+    sha1 = "78247cc32bb776f204efef17cff3095878036a40",
+    version = "2.1.1",
 )
 
 bower_archive(
     name = "paper-toggle-button",
     package = "polymerelements/paper-toggle-button",
-    sha1 = "4a2edbdb52c4531d39fe091f12de650bccda270f",
-    version = "1.2.0",
+    sha1 = "9927960afb0062726ec1b585ef3e32764c3bbac9",
+    version = "2.1.1",
 )
 
 bower_archive(
     name = "polymer",
     package = "polymer/polymer",
-    sha1 = "158443ab05ade5e2cdc24ebc01f1deef9aebac1b",
-    version = "1.11.3",
+    sha1 = "d06e17a1d8dc6187ee5aa8c5b3501da10901c82f",
+    version = "2.7.2",
 )
 
 bower_archive(
@@ -1125,13 +1144,6 @@
 )
 
 bower_archive(
-    name = "promise-polyfill",
-    package = "polymerlabs/promise-polyfill",
-    sha1 = "a3b598c06cbd7f441402e666ff748326030905d6",
-    version = "1.0.0",
-)
-
-bower_archive(
     name = "resemblejs",
     package = "rsmbl/Resemble.js",
     sha1 = "49d5f022417c389b630d6f7ee667aa9540075c42",
@@ -1150,15 +1162,15 @@
 bower_archive(
     name = "iron-test-helpers",
     package = "polymerelements/iron-test-helpers",
-    sha1 = "433b03b106f5ff32049b84150cd70938e18b67ac",
-    version = "1.2.5",
+    sha1 = "882be2d4c8714b39299b5f7bf25253c4e8a40761",
+    version = "2.0.1",
 )
 
 bower_archive(
     name = "test-fixture",
     package = "polymerelements/test-fixture",
-    sha1 = "e373bd21c069163c3a754e234d52c07c77b20d3c",
-    version = "1.1.1",
+    sha1 = "7d72ddfebf555a2dd1fc60a85427d9026b509723",
+    version = "3.0.0",
 )
 
 bower_archive(
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
deleted file mode 100755
index 2e01131..0000000
--- a/contrib/abandon_stale.py
+++ /dev/null
@@ -1,225 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# The MIT License
-#
-# Copyright 2014 Sony Mobile Communications. All rights reserved.
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-""" Script to abandon stale changes from the review server.
-
-Fetches a list of open changes that have not been updated since a given age in
-days, months or years (default 6 months), and then abandons them.
-
-Requires the user's credentials for the Gerrit server to be declared in the
-.netrc file. Supports either basic or digest authentication.
-
-Example to abandon changes that have not been updated for 3 months:
-
-  ./abandon_stale --gerrit-url http://review.example.com/ --age 3months
-
-Supports dry-run mode to only list the stale changes, but not actually
-abandon them.
-
-See the --help output for more information about options.
-
-Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2) to be installed
-and available for import.
-
-"""
-
-import logging
-import optparse
-import re
-import sys
-
-from pygerrit2.rest import GerritRestAPI
-from pygerrit2.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
-
-
-def _main():
-    parser = optparse.OptionParser()
-    parser.add_option('-g', '--gerrit-url', dest='gerrit_url',
-                      metavar='URL',
-                      default=None,
-                      help='gerrit server URL')
-    parser.add_option('-b', '--basic-auth', dest='basic_auth',
-                      action='store_true',
-                      help='(deprecated) use HTTP basic authentication instead'
-                      ' of digest')
-    parser.add_option('-d', '--digest-auth', dest='digest_auth',
-                      action='store_true',
-                      help='use HTTP digest authentication instead of basic')
-    parser.add_option('-n', '--dry-run', dest='dry_run',
-                      action='store_true',
-                      help='enable dry-run mode: show stale changes but do '
-                           'not abandon them')
-    parser.add_option('-t', '--test', dest='testmode', action='store_true',
-                      help='test mode: query changes with the `test-abandon` '
-                           'topic and ignore age option')
-    parser.add_option('-a', '--age', dest='age',
-                      metavar='AGE',
-                      default="6months",
-                      help='age of change since last update in days, months'
-                           ' or years (default: %default)')
-    parser.add_option('-m', '--message', dest='message',
-                      metavar='STRING', default=None,
-                      help='custom message to append to abandon message')
-    parser.add_option('--branch', dest='branches', metavar='BRANCH_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given branch')
-    parser.add_option('--exclude-branch', dest='exclude_branches',
-                      metavar='BRANCH_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given branch')
-    parser.add_option('--project', dest='projects', metavar='PROJECT_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given project')
-    parser.add_option('--exclude-project', dest='exclude_projects',
-                      metavar='PROJECT_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given project')
-    parser.add_option('--owner', dest='owner',
-                      metavar='USERNAME',
-                      default=None,
-                      action='store',
-                      help='only abandon changes owned by the given user')
-    parser.add_option('--exclude-wip', dest='exclude_wip',
-                      action='store_true',
-                      help='Exclude changes that are Work-in-Progress')
-    parser.add_option('-v', '--verbose', dest='verbose',
-                      action='store_true',
-                      help='enable verbose (debug) logging')
-
-    (options, _args) = parser.parse_args()
-
-    level = logging.DEBUG if options.verbose else logging.INFO
-    logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
-                        level=level)
-
-    if not options.gerrit_url:
-        logging.error("Gerrit URL is required")
-        return 1
-
-    if options.testmode:
-        message = "Abandoning in test mode"
-    else:
-        pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
-        match = pattern.match(options.age)
-        if not match:
-            logging.error("Invalid age: %s", options.age)
-            return 1
-        message = "Abandoning after %s %s or more of inactivity." % \
-            (match.group(1), match.group(2))
-
-    if options.digest_auth:
-        auth_type = HTTPDigestAuthFromNetrc
-    else:
-        auth_type = HTTPBasicAuthFromNetrc
-
-    try:
-        auth = auth_type(url=options.gerrit_url)
-        gerrit = GerritRestAPI(url=options.gerrit_url, auth=auth)
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    logging.info(message)
-    try:
-        stale_changes = []
-        offset = 0
-        step = 500
-        if options.testmode:
-            query_terms = ["status:new", "owner:self", "topic:test-abandon"]
-        else:
-            query_terms = ["status:new", "age:%s" % options.age]
-        if options.exclude_wip:
-            query_terms += ["-is:wip"]
-        if options.branches:
-            query_terms += ["branch:%s" % b for b in options.branches]
-        elif options.exclude_branches:
-            query_terms += ["-branch:%s" % b for b in options.exclude_branches]
-        if options.projects:
-            query_terms += ["project:%s" % p for p in options.projects]
-        elif options.exclude_projects:
-            query_terms = ["-project:%s" % p for p in options.exclude_projects]
-        if options.owner and not options.testmode:
-            query_terms += ["owner:%s" % options.owner]
-        query = "%20".join(query_terms)
-        while True:
-            q = query + "&o=DETAILED_ACCOUNTS&n=%d&S=%d" % (step, offset)
-            logging.debug("Query: %s", q)
-            url = "/changes/?q=" + q
-            result = gerrit.get(url)
-            logging.debug("%d changes", len(result))
-            if not result:
-                break
-            stale_changes += result
-            last = result[-1]
-            if "_more_changes" in last:
-                logging.debug("More...")
-                offset += step
-            else:
-                break
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    abandoned = 0
-    errors = 0
-    abandon_message = message
-    if options.message:
-        abandon_message += "\n\n" + options.message
-    for change in stale_changes:
-        number = change["_number"]
-        project = ""
-        if len(options.projects) != 1:
-            project = "%s: " % change["project"]
-        owner = ""
-        if options.verbose:
-            try:
-                o = change["owner"]["name"]
-            except KeyError:
-                o = "Unknown"
-            owner = " (%s)" % o
-        subject = change["subject"]
-        if len(subject) > 70:
-            subject = subject[:65] + " [...]"
-        change_id = change["id"]
-        logging.info("%s%s: %s%s", number, owner, project, subject)
-        if options.dry_run:
-            continue
-
-        try:
-            gerrit.post("/changes/" + change_id + "/abandon",
-                        json={"message": "%s" % abandon_message})
-            abandoned += 1
-        except Exception as e:
-            errors += 1
-            logging.error(e)
-    logging.info("Total %d stale open changes", len(stale_changes))
-    if not options.dry_run:
-        logging.info("Abandoned %d changes. %d errors.", abandoned, errors)
-
-
-if __name__ == "__main__":
-    sys.exit(_main())
diff --git a/contrib/benchmark-createchange.go b/contrib/benchmark-createchange.go
new file mode 100644
index 0000000..dc320d6
--- /dev/null
+++ b/contrib/benchmark-createchange.go
@@ -0,0 +1,103 @@
+// Copyright (C) 2019 Google LLC
+//
+// 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.
+
+// Program to benchmark Gerrit.  Creates pending changes in a loop,
+// which tests performance of BatchRefUpdate and Lucene indexing
+package main
+
+import (
+	"bytes"
+	"encoding/base64"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"sort"
+	"time"
+)
+
+func main() {
+	user := flag.String("user", "admin", "username for basic auth")
+	pw := flag.String("password", "secret", "HTTP password for basic auth")
+	project := flag.String("project", "", "project to create changes in")
+	gerritURL := flag.String("url", "http://localhost:8080/", "URL to gerrit instance")
+	numChanges := flag.Int("n", 100, "number of changes to create")
+	flag.Parse()
+	if *gerritURL == "" {
+		log.Fatal("provide --url")
+	}
+	if *project == "" {
+		log.Fatal("provide --project")
+	}
+
+	u, err := url.Parse(*gerritURL)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	basicAuth := fmt.Sprintf("%s:%s", *user, *pw)
+	authHeader := base64.StdEncoding.EncodeToString([]byte(basicAuth))
+
+	client := &http.Client{}
+
+	var dts []time.Duration
+	startAll := time.Now()
+	var lastSec int
+	for i := 0; i < *numChanges; i++ {
+		body := fmt.Sprintf(`{
+    "project" : "%s",
+    "subject" : "change %d",
+    "branch" : "master",
+    "status" : "NEW"
+  }`, *project, i)
+		start := time.Now()
+
+		thisSec := int(start.Sub(startAll) / time.Second)
+		if thisSec != lastSec {
+			log.Printf("change %d", i)
+		}
+		lastSec = thisSec
+
+		u.Path = "/a/changes/"
+		req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(body))
+		if err != nil {
+			log.Fatal(err)
+		}
+		req.Header.Add("Authorization", "Basic "+authHeader)
+		req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+		resp, err := client.Do(req)
+		if err != nil {
+			log.Fatal(err)
+		}
+		dt := time.Now().Sub(start)
+		dts = append(dts, dt)
+
+		if resp.StatusCode/100 == 2 {
+			continue
+		}
+		log.Println("code", resp.StatusCode)
+		io.Copy(os.Stdout, resp.Body)
+	}
+
+	sort.Slice(dts, func(i, j int) bool { return dts[i] < dts[j] })
+
+	var total time.Duration
+	for _, dt := range dts {
+		total += dt
+	}
+	log.Printf("min %v max %v median %v avg %v", dts[0], dts[len(dts)-1], dts[len(dts)/2], total/time.Duration(len(dts)))
+}
diff --git a/contrib/hooks/post-receive-move-tmp-refs b/contrib/hooks/post-receive-move-tmp-refs
new file mode 100755
index 0000000..fa0684f
--- /dev/null
+++ b/contrib/hooks/post-receive-move-tmp-refs
@@ -0,0 +1,78 @@
+#!/bin/sh
+#
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# --------------------------------------------------------
+# Install this hook script as post-receive hook in replicated repositories
+# hosted by a gerrit replica which are updated by push replication from the
+# corresponding gerrit primary node.
+#
+# In the gerrit primary node configure the replication plugin to push changes from
+# refs/changes/ to refs/tmp/changes/
+#   remote.NAME.push = +refs/changes/*:refs/tmp/changes/*
+#   remote.NAME.push = +refs/heads/*:refs/heads/*
+#   remote.NAME.push = +refs/tags/*:refs/tags/*
+# And if it's a Gerrit mirror:
+#   remote.NAME.push = +refs/meta/*:refs/meta/*
+#
+# In the replicated repository in the gerrit replica configure
+#    receive.hideRefs = refs/changes/
+# in order to not advertise the big number of refs in this namespace when
+# the gerrit primary's replication plugin is pushing a change
+#
+# Whenever a ref under refs/tmp/changes/ is arriving this hook will move it
+# to refs/changes/. This helps to avoid the large overhead of advertising all
+# refs/changes/ refs to the gerrit primary when it replicates changes to the
+# replica.
+#
+# Make this script executable then link to it in the repository you would like
+# to use it in.
+#   cd /path/to/your/repository.git
+#   ln -sf <shared hooks directory>/post-receive-move-tmp-refs hooks/post-receive
+#
+# If you want to use this by default for repositories on the Gerrit replica you
+# can set up a git template directory $TEMPLATE_DIR/hooks/post-receive and
+# configure init.templateDir in the ~/.gitconfig of the user that receives the
+# replication on the mirror host. That way when a new repository is created on
+# the primary and hence on the mirror (if configured that way) it will
+# automatically have the "tmp-refs" commit hook installed.
+# See https://git-scm.com/docs/git-init#_template_directory for details.
+
+# move new changes arriving under refs/tmp/changes/ to refs/changes/
+mv_tmp_refs()
+{
+	oldrev=$1
+	newrev=$2
+	refname=$3
+	case "$refname" in refs/tmp/changes/*)
+			short_refname=${refname##refs/tmp/changes/}
+			$(git update-ref refs/changes/$short_refname $newrev 2>/dev/null)
+			$(git update-ref -d $refname $newrev 2>/dev/null)
+			echo "moved \"$refname\" to \"refs/changes/$short_refname\""
+			;;
+	esac
+	return 0
+}
+
+GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
+if [ -z "$GIT_DIR" ]; then
+	echo >&2 "fatal: post-receive: GIT_DIR not set"
+	exit 1
+fi
+
+# read ref updates passed to post-receive hook
+while read oldrev newrev refname
+do
+	mv_tmp_refs $oldrev $newrev $refname || continue
+done
diff --git a/contrib/refresh_plugin_in_testsite.sh b/contrib/refresh_plugin_in_testsite.sh
new file mode 100755
index 0000000..bb42ce8
--- /dev/null
+++ b/contrib/refresh_plugin_in_testsite.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+#
+# 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.
+
+# This script compiles a Gerrit plugin whose name is passed as first parameter
+# and copies it over to the plugin folder of the testsite. The path to the
+# testsite needs to be provided by the variable GERRIT_TESTSITE or as second
+# parameter.
+
+SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
+GERRIT_CODE_DIR="$SCRIPT_DIR/.."
+cd "$GERRIT_CODE_DIR"
+
+if [ "$#" -lt 1 ]
+then
+  echo "No plugin name provided as first argument. Stopping."
+  exit 1
+else
+  PLUGIN_NAME="$1"
+fi
+
+
+if [ "$#" -lt 2 ]
+then
+  if [ -z ${GERRIT_TESTSITE+x} ]
+  then
+    echo "Path to local testsite is neiter set as GERRIT_TESTSITE nor passed as second argument. Stopping."
+    exit 1
+  fi
+else
+  GERRIT_TESTSITE="$2"
+fi
+
+if [ ! -d "$GERRIT_TESTSITE" ]
+then
+  echo "Testsite directory $GERRIT_TESTSITE does not exist. Stopping."
+  exit 1
+fi
+
+bazel build //plugins/"$PLUGIN_NAME"/...
+if [ $? -ne 0 ]
+then
+  echo "Building the $PLUGIN_NAME plugin failed"
+  exit 1
+fi
+
+yes | cp -f "$GERRIT_CODE_DIR/bazel-genfiles/plugins/$PLUGIN_NAME/$PLUGIN_NAME.jar" "$GERRIT_TESTSITE/plugins/"
+if [ $? -eq 0 ]
+then
+  echo "Plugin $PLUGIN_NAME copied successfully to testsite."
+fi
diff --git a/contrib/show_new_gerrit_doc_in_chrome.sh b/contrib/show_new_gerrit_doc_in_chrome.sh
new file mode 100755
index 0000000..d57bc8a
--- /dev/null
+++ b/contrib/show_new_gerrit_doc_in_chrome.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+#
+# 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.
+
+# This script builds Gerrit's documentation and shows the current state in
+# Chrome. Specific pages (e.g. rest-api-changes.txt) including anchors can be
+# passed as parameter to jump directly to them.
+
+SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
+GERRIT_CODE_DIR="$SCRIPT_DIR/.."
+cd "$GERRIT_CODE_DIR"
+
+bazel build Documentation:searchfree
+if [ $? -ne 0 ]
+then
+  echo "Building the documentation failed. Stopping."
+  exit 1
+fi
+
+TMP_DOCS_DIR=/tmp/gerrit_docs
+rm -rf "$TMP_DOCS_DIR"
+unzip bazel-bin/Documentation/searchfree.zip -d "$TMP_DOCS_DIR" </dev/null >/dev/null 2>&1 & disown
+if [ $? -ne 0 ]
+then
+  echo "Unzipping the documentation to $TMP_DOCS_DIR failed. Stopping."
+  exit 1
+fi
+
+if [ "$#" -lt 1 ]
+then
+  FILE_NAME="index.html"
+else
+  FILE_NAME="$1"
+fi
+DOC_FILE_NAME="${FILE_NAME/.txt/.html}"
+google-chrome "file:///$TMP_DOCS_DIR/Documentation/$DOC_FILE_NAME" </dev/null >/dev/null 2>&1 & disown
diff --git a/contrib/start_testsite.sh b/contrib/start_testsite.sh
new file mode 100755
index 0000000..014eba9
--- /dev/null
+++ b/contrib/start_testsite.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+#
+# 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.
+
+# This script starts the local testsite in debug mode. If the flag "-u" is
+# passed, Gerrit is built from the current state of the repository and the
+# testsite is refreshed. The path to the testsite needs to be provided by
+# the variable GERRIT_TESTSITE or as parameter (after any used flags).
+# The testsite can be stopped by interrupting this script.
+
+SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
+GERRIT_CODE_DIR="$SCRIPT_DIR/.."
+cd "$GERRIT_CODE_DIR"
+
+UPDATE=false
+while getopts ':u' flag; do
+  case "${flag}" in
+    u) UPDATE=true ;;
+  esac
+done
+shift $(($OPTIND-1))
+
+if [ "$#" -lt 1 ]
+then
+  if [ -z ${GERRIT_TESTSITE+x} ]
+  then
+    echo "Path to local testsite is neither set as GERRIT_TESTSITE nor passed as first argument. Stopping."
+    exit 1
+  fi
+else
+  GERRIT_TESTSITE="$1"
+fi
+
+if [ "$UPDATE" = true ]
+then
+  echo "Refreshing testsite"
+  bazel build gerrit
+  if [ $? -ne 0 ]
+  then
+    echo "Build failed. Stopping."
+    exit 1
+  fi
+  $(bazel info output_base)/external/local_jdk/bin/java -jar bazel-bin/gerrit.war init --batch -d "$GERRIT_TESTSITE"
+  if [ $? -ne 0 ]
+  then
+    echo "Patching the testsite failed. Stopping."
+    exit 1
+  fi
+fi
+
+$(bazel info output_base)/external/local_jdk/bin/java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 bazel-bin/gerrit.war daemon -d "$GERRIT_TESTSITE" --console-log
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 6bacc1a..425bb88 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -15,17 +15,20 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -51,9 +54,16 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -78,14 +88,6 @@
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.EmailHeader;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -123,21 +125,23 @@
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.Revisions;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.git.DelegateSystemReader;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.SshMode;
+import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -147,6 +151,8 @@
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -159,8 +165,6 @@
 import java.util.Optional;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -173,17 +177,19 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.FetchResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
 import org.eclipse.jgit.transport.TransportBundleStream;
 import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Rule;
-import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
@@ -200,8 +206,6 @@
   @ConfigSuite.Parameter public Config baseConfig;
   @ConfigSuite.Name private String configName;
 
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Rule
   public TestRule testRunner =
       new TestRule() {
@@ -216,7 +220,8 @@
               beforeTest(description);
               ProjectResetter.Config input = requireNonNull(resetProjects());
 
-              try (ProjectResetter resetter = projectResetter.builder().build(input)) {
+              try (ProjectResetter resetter =
+                  projectResetter != null ? projectResetter.builder().build(input) : null) {
                 AbstractDaemonTest.this.resetter = resetter;
                 base.evaluate();
               } finally {
@@ -288,21 +293,27 @@
   @Inject private PluginGuiceEnvironment pluginGuiceEnvironment;
   @Inject private PluginUser.Factory pluginUserFactory;
   @Inject private ProjectIndexCollection projectIndexes;
-  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private SitePaths sitePaths;
+  @Inject private ProjectOperations projectOperations;
 
   private ProjectResetter resetter;
   private List<Repository> toClose;
+  private String systemTimeZone;
+  private SystemReader oldSystemReader;
 
   @Before
   public void clearSender() {
-    sender.clear();
+    if (sender != null) {
+      sender.clear();
+    }
   }
 
   @Before
   public void startEventRecorder() {
-    eventRecorder = eventRecorderFactory.create(admin);
+    if (eventRecorderFactory != null) {
+      eventRecorder = eventRecorderFactory.create(admin);
+    }
   }
 
   @Before
@@ -317,7 +328,9 @@
 
   @After
   public void closeEventRecorder() {
-    eventRecorder.close();
+    if (eventRecorder != null) {
+      eventRecorder.close();
+    }
   }
 
   @AfterClass
@@ -384,6 +397,10 @@
   }
 
   protected void beforeTest(Description description) throws Exception {
+    // SystemReader must be overridden before creating any repos, since they read the user/system
+    // configs at initialization time, and are then stored in the RepositoryCache forever.
+    oldSystemReader = setFakeSystemReader(temporaryFolder.getRoot());
+
     this.description = description;
     GerritServer.Description classDesc =
         GerritServer.Description.forTestClass(description, configName);
@@ -395,6 +412,9 @@
       baseConfig.setString("sshd", null, "listenAddress", "off");
     }
 
+    baseConfig.unset("gerrit", null, "canonicalWebUrl");
+    baseConfig.unset("httpd", null, "listenUrl");
+
     baseConfig.setInt("index", null, "batchThreads", -1);
 
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
@@ -434,10 +454,63 @@
     atrScope.set(ctx);
     ProjectInput in = projectInput(description);
     gApi.projects().create(in);
-    project = new Project.NameKey(in.name);
+    project = Project.nameKey(in.name);
     if (!classDesc.skipProjectClone()) {
       testRepo = cloneProject(project, getCloneAsAccount(description));
     }
+
+    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
+    // clock has been set.
+    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
+    setTimeSettings(
+        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
+  }
+
+  private static SystemReader setFakeSystemReader(File tempDir) {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new DelegateSystemReader(oldSystemReader) {
+          @Override
+          public FileBasedConfig openJGitConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "jgit.config"), FS.detect());
+          }
+
+          @Override
+          public FileBasedConfig openUserConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "user.config"), FS.detect());
+          }
+
+          @Override
+          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "system.config"), FS.detect());
+          }
+        });
+    return oldSystemReader;
+  }
+
+  private void setTimeSettings(
+      boolean useSystemTime,
+      @Nullable UseClockStep useClockStep,
+      @Nullable UseTimezone useTimezone) {
+    if (useSystemTime) {
+      TestTimeUtil.useSystemTime();
+    } else if (useClockStep != null) {
+      TestTimeUtil.resetWithClockStep(useClockStep.clockStep(), useClockStep.clockStepUnit());
+      if (useClockStep.startAtEpoch()) {
+        TestTimeUtil.setClock(Timestamp.from(Instant.EPOCH));
+      }
+    }
+    if (useTimezone != null) {
+      systemTimeZone = System.setProperty("user.timezone", useTimezone.timezone());
+    }
+  }
+
+  private void resetTimeSettings() {
+    TestTimeUtil.useSystemTime();
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+      systemTimeZone = null;
+    }
   }
 
   /** Override to bind an additional Guice module */
@@ -533,7 +606,7 @@
     in.submitType = submitType;
     in.createEmptyCommit = createEmptyCommit;
     gApi.projects().create(in);
-    return new Project.NameKey(in.name);
+    return Project.nameKey(in.name);
   }
 
   protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
@@ -565,10 +638,13 @@
       repo.close();
     }
     closeSsh();
+    resetTimeSettings();
     if (server != commonServer) {
       server.close();
       server = null;
     }
+    SystemReader.setInstance(oldSystemReader);
+    oldSystemReader = null;
   }
 
   protected void closeSsh() {
@@ -691,18 +767,18 @@
     return push.to("refs/for/" + branch + "%topic=" + name(topic));
   }
 
-  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
+  protected BranchApi createBranch(BranchNameKey branch) throws Exception {
     return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
+        .name(branch.project().get())
+        .branch(branch.branch())
         .create(new BranchInput());
   }
 
-  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
+  protected BranchApi createBranchWithRevision(BranchNameKey branch, String revision)
       throws Exception {
     BranchInput in = new BranchInput();
     in.revision = revision;
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get()).create(in);
+    return gApi.projects().name(branch.project().get()).branch(branch.branch()).create(in);
   }
 
   private static final List<Character> RANDOM =
@@ -772,12 +848,15 @@
   }
 
   protected Account getAccount(Account.Id accountId) {
-    return getAccountState(accountId).getAccount();
+    return getAccountState(accountId).account();
   }
 
   protected AccountState getAccountState(Account.Id accountId) {
     Optional<AccountState> accountState = accountCache.get(accountId);
-    assertThat(accountState).named("account %s", accountId.get()).isPresent();
+    assertWithMessage("account %s", accountId.get())
+        .about(optionals())
+        .that(accountState)
+        .isPresent();
     return accountState.get();
   }
 
@@ -879,59 +958,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    allow(project, ref, permission, id);
-  }
-
-  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), permission, id, ref);
-      u.save();
-    }
-  }
-
-  protected void allowGlobalCapabilities(
-      AccountGroup.UUID id, int min, int max, String... capabilityNames) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.allow(
-            u.getConfig(), capabilityName, id, new PermissionRange(capabilityName, min, max));
-      }
-      u.save();
-    }
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.allow(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.remove(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
-  }
-
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
@@ -950,125 +976,14 @@
     }
   }
 
-  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    deny(project, ref, permission, id);
-  }
-
-  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.deny(u.getConfig(), permission, id, ref);
-      u.save();
-    }
-  }
-
-  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    return block(project, ref, permission, id);
-  }
-
-  protected PermissionRule block(
-      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      PermissionRule rule = Util.block(u.getConfig(), permission, id, ref);
-      u.save();
-      return rule;
-    }
-  }
-
-  protected void blockLabel(
-      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.LABEL + label, min, max, id, ref);
-      u.save();
-    }
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, false);
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, force, adminGroupUuid());
-  }
-
-  protected void grant(
-      Project.NameKey project,
-      String ref,
-      String permission,
-      boolean force,
-      AccountGroup.UUID groupUUID)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void grantLabel(
-      String label,
-      int min,
-      int max,
-      Project.NameKey project,
-      String ref,
-      boolean force,
-      AccountGroup.UUID groupUUID,
-      boolean exclusive)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    String permission = Permission.LABEL + label;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.setExclusiveGroup(exclusive);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      rule.setMin(min);
-      rule.setMax(max);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void removePermission(Project.NameKey project, String ref, String permission)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Remove %s on %s", permission, ref));
-      ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.clearRules();
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void blockRead(String ref) throws Exception {
-    block(ref, Permission.READ, REGISTERED_USERS);
-  }
-
   protected void blockAnonymousRead() throws Exception {
-    AccountGroup.UUID anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String allRefs = RefNames.REFS + "*";
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.READ, anonymous, allRefs);
-      Util.allow(u.getConfig(), Permission.READ, registered, allRefs);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(allRefs).group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref(allRefs).group(REGISTERED_USERS))
+        .update();
   }
 
   protected PushOneCommit.Result pushTo(String ref) throws Exception {
@@ -1077,11 +992,11 @@
   }
 
   protected void approve(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(id).current().review(ReviewInput.approve());
   }
 
   protected void recommend(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
@@ -1099,7 +1014,7 @@
   }
 
   protected PatchSet getPatchSet(PatchSet.Id psId) {
-    return changeDataFactory.create(project, psId.getParentKey()).patchSet(psId);
+    return changeDataFactory.create(project, psId.changeId()).patchSet(psId);
   }
 
   protected IdentifiedUser user(TestAccount testAccount) {
@@ -1119,7 +1034,7 @@
 
   protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
     PatchSet.Id psId = r.getPatchSetId();
-    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
+    return parseRevisionResource(psId.changeId().toString(), psId.get());
   }
 
   protected ChangeResource parseChangeResource(String changeId) throws Exception {
@@ -1135,22 +1050,13 @@
     }
   }
 
-  // TODO(hanwen): push this down.
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
-    return projectOperations.project(project).getHead(branch);
-  }
-
-  protected RevCommit getRemoteHead() throws Exception {
-    return getRemoteHead(project, "master");
-  }
-
   protected void assertMailReplyTo(Message message, String email) throws Exception {
     assertThat(message.headers()).containsKey("Reply-To");
     EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
     try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
       return fetchFromBundles(result);
     }
@@ -1162,7 +1068,7 @@
    *
    * <p>Omits NoteDb meta refs.
    */
-  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
 
     FileSystem fs = Jimfs.newFileSystem();
@@ -1170,8 +1076,8 @@
     try (OutputStream out = Files.newOutputStream(previewPath)) {
       bundles.writeTo(out);
     }
-    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
+    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
+    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, (ClassLoader) null);
         DirectoryStream<Path> dirStream =
             Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
       for (Path p : dirStream) {
@@ -1182,7 +1088,7 @@
         int len = bundleName.length();
         assertThat(bundleName).endsWith(".git");
         String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = new Project.NameKey(repoName);
+        Project.NameKey proj = Project.nameKey(repoName);
         TestRepository<?> localRepo = cloneProject(proj);
 
         try (InputStream bundleStream = Files.newInputStream(p);
@@ -1199,7 +1105,7 @@
               continue;
             }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
+            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1209,18 +1115,18 @@
   }
 
   /** Assert that the given branches have the given tree ids. */
-  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
+  protected void assertTrees(Project.NameKey proj, Map<BranchNameKey, ObjectId> trees)
       throws Exception {
     TestRepository<?> localRepo = cloneProject(proj);
     GitUtil.fetch(localRepo, "refs/*:refs/*");
-    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+    Map<BranchNameKey, RevTree> refValues = new HashMap<>();
 
-    for (Branch.NameKey b : trees.keySet()) {
-      if (!b.getParentKey().equals(proj)) {
+    for (BranchNameKey b : trees.keySet()) {
+      if (!b.project().equals(proj)) {
         continue;
       }
 
-      Ref r = localRepo.getRepository().exactRef(b.get());
+      Ref r = localRepo.getRepository().exactRef(b.branch());
       assertThat(r).isNotNull();
       RevWalk rw = localRepo.getRevWalk();
       RevCommit c = rw.parseCommit(r.getObjectId());
@@ -1324,7 +1230,7 @@
 
   protected InternalGroup group(AccountGroup.UUID groupUuid) {
     InternalGroup group = groupCache.get(groupUuid).orElse(null);
-    assertThat(group).named(groupUuid.get()).isNotNull();
+    assertWithMessage(groupUuid.get()).that(group).isNotNull();
     return group;
   }
 
@@ -1334,13 +1240,13 @@
   }
 
   protected InternalGroup group(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNotNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNotNull();
     return group;
   }
 
   protected GroupReference groupRef(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
     assertThat(group).isNotNull();
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
@@ -1362,8 +1268,8 @@
   }
 
   protected void assertGroupDoesNotExist(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNull();
   }
 
   protected void assertNotifyTo(TestAccount expected) {
@@ -1515,7 +1421,7 @@
       LabelValue... value)
       throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = category(label, value);
+      LabelType labelType = label(label, value);
       labelType.setFunction(func);
       labelType.setRefPatterns(refPatterns);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
@@ -1523,10 +1429,6 @@
     }
   }
 
-  protected void fail(@Nullable String format, Object... args) {
-    assert_().fail(format, args);
-  }
-
   protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index fa501e62..a372089 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -73,7 +73,11 @@
   }
 
   protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
-    return assertAbout(FakeEmailSenderSubject::new).that(sender);
+    return assertAbout(fakeEmailSenders()).that(sender);
+  }
+
+  protected static Subject.Factory<FakeEmailSenderSubject, FakeEmailSender> fakeEmailSenders() {
+    return FakeEmailSenderSubject::new;
   }
 
   protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
@@ -91,8 +95,8 @@
     gApi.accounts().self().setPreferences(prefs);
   }
 
-  protected static class FakeEmailSenderSubject
-      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+  protected static class FakeEmailSenderSubject extends Subject {
+    private final FakeEmailSender fakeEmailSender;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -100,10 +104,11 @@
 
     FakeEmailSenderSubject(FailureMetadata failureMetadata, FakeEmailSender target) {
       super(failureMetadata, target);
+      fakeEmailSender = target;
     }
 
     public FakeEmailSenderSubject didNotSend() {
-      Message message = actual().peekMessage();
+      Message message = fakeEmailSender.peekMessage();
       if (message != null) {
         failWithoutActual(fact("expected no message", message));
       }
@@ -111,7 +116,7 @@
     }
 
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      message = actual().nextMessage();
+      message = fakeEmailSender.nextMessage();
       if (message == null) {
         failWithoutActual(fact("expected message", "not sent"));
       }
@@ -140,9 +145,7 @@
                     : header));
       }
 
-      // Return a named subject that displays a human-readable table of
-      // recipients.
-      return named(recipientMapToString(recipients, users::emailToName));
+      return this;
     }
 
     private static String recipientMapToString(
@@ -203,8 +206,9 @@
       if (recipients.get(type).contains(email) != expected) {
         failWithoutActual(
             fact(
-                expected ? "should notify" : "shouldn't notify",
-                type + ": " + users.emailToName(email)));
+                expected ? "expected to notify" : "expected not to notify",
+                type + ": " + users.emailToName(email)),
+            fact("but notified", recipientMapToString(recipients, users::emailToName)));
       }
       if (expected) {
         accountedFor.add(email);
@@ -429,7 +433,7 @@
               .reviewer(REVIEWER_BY_EMAIL)
               .reviewer(ccer.email(), ReviewerState.CC, false)
               .reviewer(CC_BY_EMAIL, ReviewerState.CC, false);
-      ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
       supportReviewersByEmail = true;
       if (result.reviewers.values().stream().anyMatch(v -> v.error != null)) {
         supportReviewersByEmail = false;
@@ -437,7 +441,7 @@
             ReviewInput.noScore()
                 .reviewer(reviewer.email())
                 .reviewer(ccer.email(), ReviewerState.CC, false);
-        result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+        result = gApi.changes().id(r.getChangeId()).current().review(in);
       }
       Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
     }
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index ccd30ab..020602b 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -22,11 +22,11 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.restapi.change.GetChange;
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index aeae2c2..75d0d2f 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
@@ -76,7 +76,7 @@
     if (account != null) {
       return account;
     }
-    Account.Id id = new Account.Id(sequences.nextAccountId());
+    Account.Id id = Account.id(sequences.nextAccountId());
 
     List<ExternalId> extIds = new ArrayList<>(2);
     String httpPass = null;
@@ -98,7 +98,7 @@
 
     if (groupNames != null) {
       for (String n : groupNames) {
-        AccountGroup.NameKey k = new AccountGroup.NameKey(n);
+        AccountGroup.NameKey k = AccountGroup.nameKey(n);
         Optional<InternalGroup> group = groupCache.get(k);
         if (!group.isPresent()) {
           throw new NoSuchGroupException(n);
diff --git a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
new file mode 100644
index 0000000..88b97c7
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+
+/** Checks if an account is indexed the correct number of times. */
+public class AccountIndexedCounter implements AccountIndexedListener {
+  private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+  @Override
+  public void onAccountIndexed(int id) {
+    countsByAccount.incrementAndGet(id);
+  }
+
+  public void clear() {
+    countsByAccount.clear();
+  }
+
+  public void assertReindexOf(TestAccount testAccount) {
+    assertReindexOf(testAccount, 1);
+  }
+
+  public void assertReindexOf(AccountInfo accountInfo) {
+    assertReindexOf(Account.id(accountInfo._accountId), 1);
+  }
+
+  public void assertReindexOf(TestAccount testAccount, long expectedCount) {
+    assertThat(countsByAccount.asMap()).containsExactly(testAccount.id().get(), expectedCount);
+    clear();
+  }
+
+  public void assertReindexOf(Account.Id accountId, long expectedCount) {
+    assertThat(countsByAccount.asMap()).containsEntry(accountId.get(), expectedCount);
+    countsByAccount.remove(accountId.get());
+  }
+
+  public void assertNoReindex() {
+    assertThat(countsByAccount.asMap()).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 0e5d040..646d8f0 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -2,6 +2,82 @@
 load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
+FUNCTION_SRCS = [
+    "testsuite/ThrowingConsumer.java",
+    "testsuite/ThrowingFunction.java",
+]
+
+DEPLOY_ENV = [
+    "//java/com/google/gerrit/exceptions",
+    "//java/com/google/gerrit/gpg",
+    "//java/com/google/gerrit/git",
+    "//java/com/google/gerrit/index:query_exception",
+    "//java/com/google/gerrit/launcher",
+    "//java/com/google/gerrit/lifecycle",
+    "//java/com/google/gerrit/common:annotations",
+    "//java/com/google/gerrit/common:server",
+    "//java/com/google/gerrit/entities",
+    "//java/com/google/gerrit/extensions:api",
+    "//java/com/google/gerrit/httpd",
+    "//java/com/google/gerrit/index",
+    "//java/com/google/gerrit/index/project",
+    "//java/com/google/gerrit/json",
+    "//java/com/google/gerrit/lucene",
+    "//java/com/google/gerrit/mail",
+    "//java/com/google/gerrit/metrics",
+    "//java/com/google/gerrit/server",
+    "//java/com/google/gerrit/server/audit",
+    "//java/com/google/gerrit/server/git/receive",
+    "//java/com/google/gerrit/server/logging",
+    "//java/com/google/gerrit/server/restapi",
+    "//java/com/google/gerrit/server/schema",
+    "//java/com/google/gerrit/server/util/git",
+    "//java/com/google/gerrit/server/util/time",
+    "//java/com/google/gerrit/sshd",
+    "//lib/auto:auto-value",
+    "//lib/auto:auto-value-annotations",
+    "//lib:args4j",
+    "//lib:gson",
+    "//lib:guava-retrying",
+    "//lib:jgit",
+    "//lib:jsch",
+    "//lib/commons:compress",
+    "//lib/commons:lang",
+    "//lib/flogger:api",
+    "//lib/guice",
+    "//lib/guice:guice-assistedinject",
+    "//lib/guice:guice-servlet",
+    "//lib/mail",
+    "//lib/mina:sshd",
+    "//lib:guava",
+    "//lib/bouncycastle:bcpg",
+    "//lib/bouncycastle:bcprov",
+    "//prolog:gerrit-prolog-common",
+]
+
+TEST_DEPS = [
+    "//java/com/google/gerrit/httpd/auth/openid",
+    "//java/com/google/gerrit/pgm",
+    "//java/com/google/gerrit/pgm/http/jetty",
+    "//java/com/google/gerrit/pgm/util",
+    "//java/com/google/gerrit/truth",
+    "//java/com/google/gerrit/acceptance/testsuite/project",
+    "//java/com/google/gerrit/server/group/testing",
+    "//java/com/google/gerrit/server/project/testing:project-test-util",
+    "//java/com/google/gerrit/testing:gerrit-test-util",
+    "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+    "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
+    "//java/com/google/gerrit/gpg/testing:gpg-test-util",
+    "//java/com/google/gerrit/git/testing",
+]
+
+PGM_DEPLOY_ENV = [
+    "//lib:caffeine",
+    "//lib:caffeine-guava",
+    "//lib/jackson:jackson-core",
+    "//lib/prolog:cafeteria",
+]
+
 java_library(
     name = "lib",
     testonly = True,
@@ -10,124 +86,53 @@
     visibility = ["//visibility:public"],
     exports = [
         ":framework-lib",
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
-        "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
-        "//java/com/google/gerrit/git/testing",
-        "//java/com/google/gerrit/gpg/testing:gpg-test-util",
-        "//java/com/google/gerrit/httpd",
-        "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/launcher",
-        "//java/com/google/gerrit/lucene",
-        "//java/com/google/gerrit/mail",
-        "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/pgm",
-        "//java/com/google/gerrit/pgm/init",
-        "//java/com/google/gerrit/pgm/util",
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/audit",
-        "//java/com/google/gerrit/server/git/receive",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/server/restapi",
-        "//java/com/google/gerrit/sshd",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:args4j",
-        "//lib:gson",
-        "//lib:guava-retrying",
-        "//lib:h2",
-        "//lib:jimfs",
-        "//lib:jsch",
-        "//lib:servlet-api-3_1-without-neverlink",
-        "//lib/bouncycastle:bcpg",
-        "//lib/bouncycastle:bcprov",
-        "//lib/commons:compress",
-        "//lib/flogger:api",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/mina:sshd",
-        "//prolog:gerrit-prolog-common",
-    ],
+    ] + DEPLOY_ENV + TEST_DEPS,
 )
 
 java_binary(
     name = "framework",
     testonly = True,
+    deploy_env = [":framework-deploy-env"],
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [":framework-lib"],
 )
 
+java_binary(
+    name = "framework-deploy-env",
+    testonly = True,
+    main_class = "Dummy",
+    runtime_deps = DEPLOY_ENV + PGM_DEPLOY_ENV,
+)
+
 java_library2(
     name = "framework-lib",
     testonly = True,
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = FUNCTION_SRCS,
+    ),
     exported_deps = [
-        "//java/com/google/gerrit/exceptions",
-        "//java/com/google/gerrit/gpg",
-        "//java/com/google/gerrit/httpd/auth/openid",
-        "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/launcher",
-        "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/pgm:daemon",
-        "//java/com/google/gerrit/pgm/http/jetty",
-        "//java/com/google/gerrit/pgm/util",
-        "//java/com/google/gerrit/server/group/testing",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
+        ":function",
+        "//lib:jgit-junit",
         "//lib:jimfs",
-        "//lib/auto:auto-value",
-        "//lib/auto:auto-value-annotations",
+        "//lib:servlet-api",
         "//lib/httpcomponents:fluent-hc",
         "//lib/httpcomponents:httpclient",
         "//lib/httpcomponents:httpcore",
-        "//lib/jetty:servlet",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
-        "//prolog:gerrit-prolog-common",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/httpd",
-        "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/lucene",
-        "//java/com/google/gerrit/mail",
-        "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/audit",
-        "//java/com/google/gerrit/server/git/receive",
-        "//java/com/google/gerrit/server/restapi",
-        "//java/com/google/gerrit/server/schema",
-        "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/sshd",
-        "//lib:args4j",
-        "//lib:gson",
-        "//lib:guava-retrying",
-        "//lib:jsch",
-        "//lib:servlet-api-3_1",
-        "//lib/commons:lang",
         "//lib/greenmail",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/mail",
-        "//lib/mina:sshd",
-    ],
+    ] + TEST_DEPS,
+    visibility = ["//visibility:public"],
+    deps = DEPLOY_ENV,
+)
+
+java_library(
+    name = "function",
+    srcs = FUNCTION_SRCS,
+    visibility = ["//visibility:public"],
 )
 
 java_doc(
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 27ed603..1ff7d0e 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -45,9 +45,8 @@
     assertReindexOf(info, 1);
   }
 
-  public void assertReindexOf(ChangeInfo info, int expectedCount) {
-    assertThat(getCount(info)).isEqualTo(expectedCount);
-    assertThat(countsByChange).hasSize(1);
+  public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
     clear();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 91baafb..271d15c 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountIndex;
 
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index a32c6d1..34f72f5c 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index 2524a76..ed119ff 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Project;
 
 /**
  * This class wraps an index and assumes the search index can't handle any queries. However, it does
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
new file mode 100644
index 0000000..f9116a1
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -0,0 +1,245 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GroupIndexedListener;
+import com.google.gerrit.extensions.events.ProjectIndexedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ExtensionRegistry {
+  private final DynamicSet<AccountIndexedListener> accountIndexedListeners;
+  private final DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+  private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
+  private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
+  private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final DynamicSet<ExceptionHook> exceptionHooks;
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
+  private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  private final DynamicSet<SubmitRule> submitRules;
+  private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  private final DynamicSet<ChangeETagComputation> changeETagComputations;
+  private final DynamicSet<ActionVisitor> actionVisitors;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final DynamicSet<CommentAddedListener> commentAddedListeners;
+  private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
+  private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
+  private final DynamicSet<GroupBackend> groupBackends;
+  private final DynamicSet<AccountActivationValidationListener>
+      accountActivationValidationListeners;
+  private final DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+
+  @Inject
+  ExtensionRegistry(
+      DynamicSet<AccountIndexedListener> accountIndexedListeners,
+      DynamicSet<ChangeIndexedListener> changeIndexedListeners,
+      DynamicSet<GroupIndexedListener> groupIndexedListeners,
+      DynamicSet<ProjectIndexedListener> projectIndexedListeners,
+      DynamicSet<CommitValidationListener> commitValidationListeners,
+      DynamicSet<ExceptionHook> exceptionHooks,
+      DynamicSet<PerformanceLogger> performanceLoggers,
+      DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
+      DynamicSet<SubmitRule> submitRules,
+      DynamicSet<ChangeMessageModifier> changeMessageModifiers,
+      DynamicSet<ChangeETagComputation> changeETagComputations,
+      DynamicSet<ActionVisitor> actionVisitors,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
+      DynamicSet<CommentAddedListener> commentAddedListeners,
+      DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
+      DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
+      DynamicSet<GroupBackend> groupBackends,
+      DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
+      DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners) {
+    this.accountIndexedListeners = accountIndexedListeners;
+    this.changeIndexedListeners = changeIndexedListeners;
+    this.groupIndexedListeners = groupIndexedListeners;
+    this.projectIndexedListeners = projectIndexedListeners;
+    this.commitValidationListeners = commitValidationListeners;
+    this.exceptionHooks = exceptionHooks;
+    this.performanceLoggers = performanceLoggers;
+    this.projectCreationValidationListeners = projectCreationValidationListeners;
+    this.submitRules = submitRules;
+    this.changeMessageModifiers = changeMessageModifiers;
+    this.changeETagComputations = changeETagComputations;
+    this.actionVisitors = actionVisitors;
+    this.downloadSchemes = downloadSchemes;
+    this.refOperationValidationListeners = refOperationValidationListeners;
+    this.commentAddedListeners = commentAddedListeners;
+    this.refUpdatedListeners = refUpdatedListeners;
+    this.fileHistoryWebLinks = fileHistoryWebLinks;
+    this.patchSetWebLinks = patchSetWebLinks;
+    this.revisionCreatedListeners = revisionCreatedListeners;
+    this.groupBackends = groupBackends;
+    this.accountActivationValidationListeners = accountActivationValidationListeners;
+    this.onSubmitValidationListeners = onSubmitValidationListeners;
+  }
+
+  public Registration newRegistration() {
+    return new Registration();
+  }
+
+  @SuppressWarnings("FunctionalInterfaceClash")
+  public class Registration implements AutoCloseable {
+    private final List<RegistrationHandle> registrationHandles = new ArrayList<>();
+
+    public Registration add(AccountIndexedListener accountIndexedListener) {
+      return add(accountIndexedListeners, accountIndexedListener);
+    }
+
+    public Registration add(ChangeIndexedListener changeIndexedListener) {
+      return add(changeIndexedListeners, changeIndexedListener);
+    }
+
+    public Registration add(GroupIndexedListener groupIndexedListener) {
+      return add(groupIndexedListeners, groupIndexedListener);
+    }
+
+    public Registration add(ProjectIndexedListener projectIndexedListener) {
+      return add(projectIndexedListeners, projectIndexedListener);
+    }
+
+    public Registration add(CommitValidationListener commitValidationListener) {
+      return add(commitValidationListeners, commitValidationListener);
+    }
+
+    public Registration add(ExceptionHook exceptionHook) {
+      return add(exceptionHooks, exceptionHook);
+    }
+
+    public Registration add(PerformanceLogger performanceLogger) {
+      return add(performanceLoggers, performanceLogger);
+    }
+
+    public Registration add(ProjectCreationValidationListener projectCreationListener) {
+      return add(projectCreationValidationListeners, projectCreationListener);
+    }
+
+    public Registration add(SubmitRule submitRule) {
+      return add(submitRules, submitRule);
+    }
+
+    public Registration add(ChangeMessageModifier changeMessageModifier) {
+      return add(changeMessageModifiers, changeMessageModifier);
+    }
+
+    public Registration add(ChangeMessageModifier changeMessageModifier, String exportName) {
+      return add(changeMessageModifiers, changeMessageModifier, exportName);
+    }
+
+    public Registration add(ChangeETagComputation changeETagComputation) {
+      return add(changeETagComputations, changeETagComputation);
+    }
+
+    public Registration add(ActionVisitor actionVisitor) {
+      return add(actionVisitors, actionVisitor);
+    }
+
+    public Registration add(DownloadScheme downloadScheme, String exportName) {
+      return add(downloadSchemes, downloadScheme, exportName);
+    }
+
+    public Registration add(RefOperationValidationListener refOperationValidationListener) {
+      return add(refOperationValidationListeners, refOperationValidationListener);
+    }
+
+    public Registration add(CommentAddedListener commentAddedListener) {
+      return add(commentAddedListeners, commentAddedListener);
+    }
+
+    public Registration add(GitReferenceUpdatedListener refUpdatedListener) {
+      return add(refUpdatedListeners, refUpdatedListener);
+    }
+
+    public Registration add(FileHistoryWebLink fileHistoryWebLink) {
+      return add(fileHistoryWebLinks, fileHistoryWebLink);
+    }
+
+    public Registration add(PatchSetWebLink patchSetWebLink) {
+      return add(patchSetWebLinks, patchSetWebLink);
+    }
+
+    public Registration add(RevisionCreatedListener revisionCreatedListener) {
+      return add(revisionCreatedListeners, revisionCreatedListener);
+    }
+
+    public Registration add(GroupBackend groupBackend) {
+      return add(groupBackends, groupBackend);
+    }
+
+    public Registration add(
+        AccountActivationValidationListener accountActivationValidationListener) {
+      return add(accountActivationValidationListeners, accountActivationValidationListener);
+    }
+
+    public Registration add(OnSubmitValidationListener onSubmitValidationListener) {
+      return add(onSubmitValidationListeners, onSubmitValidationListener);
+    }
+
+    private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
+      return add(dynamicSet, extension, "gerrit");
+    }
+
+    private <T> Registration add(DynamicSet<T> dynamicSet, T extension, String exportname) {
+      RegistrationHandle registrationHandle = dynamicSet.add(exportname, extension);
+      registrationHandles.add(registrationHandle);
+      return this;
+    }
+
+    private <T> Registration add(DynamicMap<T> dynamicMap, T extension, String exportName) {
+      RegistrationHandle registrationHandle =
+          ((PrivateInternals_DynamicMapImpl<T>) dynamicMap)
+              .put("myPlugin", exportName, Providers.of(extension));
+      registrationHandles.add(registrationHandle);
+      return this;
+    }
+
+    @Override
+    public void close() {
+      registrationHandles.forEach(h -> h.remove());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GcAssert.java b/java/com/google/gerrit/acceptance/GcAssert.java
index b9ef629..bef3323 100644
--- a/java/com/google/gerrit/acceptance/GcAssert.java
+++ b/java/com/google/gerrit/acceptance/GcAssert.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.File;
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 893106b..02690ae 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
+import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.testing.FakeEmailSender;
@@ -104,6 +105,9 @@
           has(Sandboxed.class, testDesc.getTestClass()),
           has(SkipProjectClone.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
+          false, // @UseSystemTime is only valid on methods.
+          get(UseClockStep.class, testDesc.getTestClass()),
+          get(UseTimezone.class, testDesc.getTestClass()),
           null, // @GerritConfig is only valid on methods.
           null, // @GerritConfigs is only valid on methods.
           null, // @GlobalPluginConfig is only valid on methods.
@@ -112,6 +116,15 @@
 
     public static Description forTestMethod(
         org.junit.runner.Description testDesc, String configName) {
+      UseClockStep useClockStep = testDesc.getAnnotation(UseClockStep.class);
+      if (testDesc.getAnnotation(UseSystemTime.class) == null && useClockStep == null) {
+        // Only read the UseClockStep from the class if on method level neither @UseSystemTime nor
+        // @UseClockStep have been used.
+        // If the method defines @UseSystemTime or @UseClockStep it should overwrite @UseClockStep
+        // on class level.
+        useClockStep = get(UseClockStep.class, testDesc.getTestClass());
+      }
+
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
@@ -126,6 +139,11 @@
               || has(SkipProjectClone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(UseSsh.class) != null
               || has(UseSsh.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(UseSystemTime.class) != null,
+          useClockStep,
+          testDesc.getAnnotation(UseTimezone.class) != null
+              ? testDesc.getAnnotation(UseTimezone.class)
+              : get(UseTimezone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(GerritConfig.class),
           testDesc.getAnnotation(GerritConfigs.class),
           testDesc.getAnnotation(GlobalPluginConfig.class),
@@ -141,6 +159,16 @@
       return false;
     }
 
+    @Nullable
+    private static <T extends Annotation> T get(Class<T> annotation, Class<?> clazz) {
+      for (; clazz != null; clazz = clazz.getSuperclass()) {
+        if (clazz.getAnnotation(annotation) != null) {
+          return clazz.getAnnotation(annotation);
+        }
+      }
+      return null;
+    }
+
     abstract org.junit.runner.Description testDescription();
 
     @Nullable
@@ -160,6 +188,14 @@
       return useSshAnnotation() && SshMode.useSsh();
     }
 
+    abstract boolean useSystemTime();
+
+    @Nullable
+    abstract UseClockStep useClockStep();
+
+    @Nullable
+    abstract UseTimezone useTimezone();
+
     @Nullable
     abstract GerritConfig config();
 
@@ -173,12 +209,15 @@
     abstract GlobalPluginConfigs pluginConfigs();
 
     private void checkValidAnnotations() {
+      if (useClockStep() != null && useSystemTime()) {
+        throw new IllegalStateException("Use either @UseClockStep or @UseSystemTime, not both");
+      }
       if (configs() != null && config() != null) {
-        throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig not both");
+        throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig, not both");
       }
       if (pluginConfigs() != null && pluginConfig() != null) {
         throw new IllegalStateException(
-            "Use either @GlobalPluginConfig or @GlobalPluginConfigs not both");
+            "Use either @GlobalPluginConfig or @GlobalPluginConfigs, not both");
       }
       if ((pluginConfigs() != null || pluginConfig() != null) && memory()) {
         throw new IllegalStateException("Must use @UseLocalDisk with @GlobalPluginConfig(s)");
@@ -354,7 +393,7 @@
       @Nullable InMemoryRepositoryManager inMemoryRepoManager)
       throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
-    daemon.setSlave(isSlave(baseConfig) || cfg.getBoolean("container", "slave", false));
+    daemon.setReplica(ReplicaUtil.isReplica(baseConfig) || ReplicaUtil.isReplica(cfg));
     mergeTestConfig(cfg);
     // Set the log4j configuration to an invalid one to prevent system logs
     // from getting configured and creating log files.
@@ -367,7 +406,8 @@
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
     daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0, isSlave(baseConfig)));
+    daemon.setLuceneModule(
+        LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
     daemon.setDatabaseForTesting(
         ImmutableList.of(
             new InMemoryTestingDatabaseModule(cfg, site, inMemoryRepoManager),
@@ -383,10 +423,6 @@
     return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
   }
 
-  private static boolean isSlave(Config baseConfig) {
-    return baseConfig.getBoolean("container", "slave", false);
-  }
-
   private static GerritServer startOnDisk(
       Description desc,
       Path site,
@@ -427,9 +463,13 @@
   private static void mergeTestConfig(Config cfg) {
     String forceEphemeralPort = String.format("%s:0", getLocalHost().getHostName());
     String url = "http://" + forceEphemeralPort + "/";
-    cfg.setString("gerrit", null, "canonicalWebUrl", url);
-    cfg.setString("httpd", null, "listenUrl", url);
 
+    if (cfg.getString("gerrit", null, "canonicalWebUrl") == null) {
+      cfg.setString("gerrit", null, "canonicalWebUrl", url);
+    }
+    if (cfg.getString("httpd", null, "listenUrl") == null) {
+      cfg.setString("httpd", null, "listenUrl", url);
+    }
     if (cfg.getString("sshd", null, "listenAddress") == null) {
       cfg.setString("sshd", null, "listenAddress", forceEphemeralPort);
     }
@@ -445,11 +485,13 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
-    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    if (cfg.getString("index", null, "reindexAfterRefUpdate") == null) {
+      cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    }
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
-    Injector sysInjector = get(daemon, "sysInjector");
+    Injector sysInjector = getInjector(daemon, "sysInjector");
     Module module =
         new FactoryModule() {
           @Override
@@ -482,13 +524,14 @@
     return sysInjector.createChildInjector(module);
   }
 
-  @SuppressWarnings("unchecked")
-  private static <T> T get(Object obj, String field)
+  private static Injector getInjector(Object obj, String field)
       throws SecurityException, NoSuchFieldException, IllegalArgumentException,
           IllegalAccessException {
     Field f = obj.getClass().getDeclaredField(field);
     f.setAccessible(true);
-    return (T) f.get(obj);
+    Object v = f.get(obj);
+    checkArgument(v instanceof Injector, "not an Injector: %s", v);
+    return (Injector) f.get(obj);
   }
 
   private static InetAddress getLocalHost() {
@@ -544,7 +587,7 @@
     Path site = server.testInjector.getInstance(Key.get(Path.class, SitePath.class));
 
     Config cfg = server.testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    cfg.setBoolean("container", null, "slave", true);
+    cfg.setBoolean("container", null, "replica", true);
 
     InMemoryRepositoryManager inMemoryRepoManager = null;
     if (hasBinding(server.testInjector, InMemoryRepositoryManager.class)) {
diff --git a/java/com/google/gerrit/acceptance/GitClientVersion.java b/java/com/google/gerrit/acceptance/GitClientVersion.java
new file mode 100644
index 0000000..4c9a32d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GitClientVersion.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.util.stream.Collectors.joining;
+
+import java.util.stream.IntStream;
+
+/** Class to parse and represent version of git-core client */
+public class GitClientVersion implements Comparable<GitClientVersion> {
+  private final int v[];
+
+  /**
+   * Constructor to represent instance for minimum supported git-core version
+   *
+   * @param parts version passed as single digits
+   */
+  public GitClientVersion(int... parts) {
+    this.v = parts;
+  }
+
+  /**
+   * Parse the git-core version as returned by git version command
+   *
+   * @param version String returned by git version command
+   */
+  public GitClientVersion(String version) {
+    // "git version x.y.z", at Google "git version x.y.z.gXXXXXXXXXX-goog"
+    String parts[] = version.split(" ")[2].split("\\.");
+    int numParts = Math.min(parts.length, 3); // ignore Google-specific part of the version
+    v = new int[numParts];
+    for (int i = 0; i < numParts; i++) {
+      v[i] = Integer.valueOf(parts[i]);
+    }
+  }
+
+  @Override
+  public int compareTo(GitClientVersion o) {
+    int m = Math.max(v.length, o.v.length);
+    for (int i = 0; i < m; i++) {
+      int l = i < v.length ? v[i] : 0;
+      int r = i < o.v.length ? o.v[i] : 0;
+      if (l != r) {
+        return l < r ? -1 : 1;
+      }
+    }
+    return 0;
+  }
+
+  @Override
+  public String toString() {
+    return IntStream.of(v).mapToObj(String::valueOf).collect(joining("."));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index cdfdae7..ae72793 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.KeyPair;
@@ -109,19 +110,14 @@
       throws Exception {
     DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
 
-    FS fs = FS.detect();
-
-    // Avoid leaking user state into our tests.
-    fs.setUserHome(null);
-
-    InMemoryRepository dest =
-        new InMemoryRepository.Builder()
-            .setRepositoryDescription(desc)
-            // SshTransport depends on a real FS to read ~/.ssh/config, but
-            // InMemoryRepository by default uses a null FS.
-            // TODO(dborowitz): Remove when we no longer depend on SSH.
-            .setFS(fs)
-            .build();
+    InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc);
+    if (uri.startsWith("ssh://")) {
+      // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default
+      // uses a null FS.
+      // Avoid leaking user state into our tests.
+      b.setFS(FS.detect().setUserHome(null));
+    }
+    InMemoryRepository dest = b.build();
     Config cfg = dest.getConfig();
     cfg.setString("remote", "origin", "url", uri);
     cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
@@ -134,11 +130,6 @@
     return testRepo;
   }
 
-  public static TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey project, SshSession sshSession) throws Exception {
-    return cloneProject(project, sshSession.getUrl() + "/" + project.get());
-  }
-
   public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
     TagCommand cmd =
@@ -209,13 +200,13 @@
 
   public static void assertPushOk(PushResult result, String ref) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
+    assertWithMessage(rru.toString()).that(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
   }
 
   public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus())
-        .named(rru.toString())
+    assertWithMessage(rru.toString())
+        .that(rru.getStatus())
         .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     assertThat(rru.getMessage()).isEqualTo(expectedMessage);
   }
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index a704d2f..a3207e2 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -55,8 +55,6 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-
-    // TODO(dborowitz): Use jimfs.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 69715a9..feda6bf 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -21,24 +21,24 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.InProcessProtocol.Context;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.config.GerritRequestModule;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
+import com.google.gerrit.server.git.PermissionAwareRepositoryManager;
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.UsersSelfAdvertiseRefsHook;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -90,8 +90,6 @@
       @Provides
       @RemotePeer
       SocketAddress getSocketAddress() {
-        // TODO(dborowitz): Could potentially fake this with thread ID or
-        // something.
         throw new OutOfScopeException("No remote peer in acceptance tests");
       }
     };
@@ -203,6 +201,7 @@
     private final ThreadLocalRequestContext threadContext;
     private final ProjectCache projectCache;
     private final PermissionBackend permissionBackend;
+    private final UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook;
 
     @Inject
     Upload(
@@ -212,7 +211,8 @@
         UploadValidators.Factory uploadValidatorsFactory,
         ThreadLocalRequestContext threadContext,
         ProjectCache projectCache,
-        PermissionBackend permissionBackend) {
+        PermissionBackend permissionBackend,
+        UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook) {
       this.transferConfig = transferConfig;
       this.uploadPackInitializers = uploadPackInitializers;
       this.preUploadHooks = preUploadHooks;
@@ -220,6 +220,7 @@
       this.threadContext = threadContext;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
+      this.usersSelfAdvertiseRefsHook = usersSelfAdvertiseRefsHook;
     }
 
     @Override
@@ -249,12 +250,17 @@
       if (projectState == null) {
         throw new RuntimeException("can't load project state for " + req.project.get());
       }
-      UploadPack up = new UploadPack(repo);
+      Repository permissionAwareRepository = PermissionAwareRepositoryManager.wrap(repo, perm);
+      UploadPack up = new UploadPack(permissionAwareRepository);
       up.setPackConfig(transferConfig.getPackConfig());
       up.setTimeout(transferConfig.getTimeout());
-      up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
+      if (projectState.isAllUsers()) {
+        up.setAdvertiseRefsHook(usersSelfAdvertiseRefsHook);
+      }
       List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
-      hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
+      hooks.add(
+          uploadValidatorsFactory.create(
+              projectState.getProject(), permissionAwareRepository, "localhost-test"));
       up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
       uploadPackInitializers.runEach(initializer -> initializer.init(req.project, up));
       return up;
@@ -343,7 +349,7 @@
             ImmutableList.<PostReceiveHook>builder()
                 .add(
                     (pack, commands) -> {
-                      if (affectsSize(pack, commands)) {
+                      if (affectsSize(pack)) {
                         try {
                           quotaBackend
                               .user(identifiedUser)
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index ea958f6..a528974 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
+import static com.google.gerrit.entities.RefNames.REFS_USERS;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
@@ -23,11 +23,11 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.RefState;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupIncludeCache;
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index e15dd40..3ccbe4d 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static org.junit.Assert.assertEquals;
 
@@ -24,9 +25,9 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -395,15 +396,15 @@
     public void assertErrorStatus() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus())
-          .named(message(refUpdate))
+      assertWithMessage(message(refUpdate))
+          .that(refUpdate.getStatus())
           .isEqualTo(Status.REJECTED_OTHER_REASON);
     }
 
     private void assertStatus(Status expectedStatus, String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
+      assertWithMessage(message(refUpdate)).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
       if (expectedMessage == null) {
         assertThat(refUpdate.getMessage()).isNull();
       } else {
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
index 19910db..e943519 100644
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.query.change.ChangeData;
 
diff --git a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
index bd8a926..b985e40 100644
--- a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
+++ b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
 import java.io.IOException;
@@ -50,8 +51,8 @@
 
   @Override
   public void start() {
-    // Gerrit slaves without a reindex
-    if (cfg.getBoolean("container", "slave", false)
+    // Gerrit replicas without a reindex
+    if (ReplicaUtil.isReplica(cfg)
         && !cfg.getBoolean("index", "scheduledIndexer", "runOnStartup", true)) {
       return;
     }
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index e8de5c6..a045d80 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -17,13 +17,23 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_UNPROCESSABLE_ENTITY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static javax.servlet.http.HttpServletResponse.SC_CREATED;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
+import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.net.URI;
-import org.apache.http.HttpStatus;
 
 public class RestResponse extends HttpResponse {
 
@@ -47,47 +57,47 @@
   }
 
   public void assertOK() throws Exception {
-    assertStatus(HttpStatus.SC_OK);
+    assertStatus(SC_OK);
   }
 
   public void assertNotFound() throws Exception {
-    assertStatus(HttpStatus.SC_NOT_FOUND);
+    assertStatus(SC_NOT_FOUND);
   }
 
   public void assertConflict() throws Exception {
-    assertStatus(HttpStatus.SC_CONFLICT);
+    assertStatus(SC_CONFLICT);
   }
 
   public void assertForbidden() throws Exception {
-    assertStatus(HttpStatus.SC_FORBIDDEN);
+    assertStatus(SC_FORBIDDEN);
   }
 
   public void assertNoContent() throws Exception {
-    assertStatus(HttpStatus.SC_NO_CONTENT);
+    assertStatus(SC_NO_CONTENT);
   }
 
   public void assertBadRequest() throws Exception {
-    assertStatus(HttpStatus.SC_BAD_REQUEST);
+    assertStatus(SC_BAD_REQUEST);
   }
 
   public void assertUnprocessableEntity() throws Exception {
-    assertStatus(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    assertStatus(SC_UNPROCESSABLE_ENTITY);
   }
 
   public void assertMethodNotAllowed() throws Exception {
-    assertStatus(HttpStatus.SC_METHOD_NOT_ALLOWED);
+    assertStatus(SC_METHOD_NOT_ALLOWED);
   }
 
   public void assertCreated() throws Exception {
-    assertStatus(HttpStatus.SC_CREATED);
+    assertStatus(SC_CREATED);
   }
 
   public void assertPreconditionFailed() throws Exception {
-    assertStatus(HttpStatus.SC_PRECONDITION_FAILED);
+    assertStatus(SC_PRECONDITION_FAILED);
   }
 
   public void assertTemporaryRedirect(String path) throws Exception {
-    assertStatus(HttpStatus.SC_MOVED_TEMPORARILY);
+    assertStatus(SC_MOVED_TEMPORARILY);
     assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/SshdModule.java b/java/com/google/gerrit/acceptance/SshdModule.java
index 185d6e2..873ba177 100644
--- a/java/com/google/gerrit/acceptance/SshdModule.java
+++ b/java/com/google/gerrit/acceptance/SshdModule.java
@@ -34,7 +34,7 @@
     if (keys == null) {
       keys = new SimpleGeneratorHostKeyProvider();
       keys.setAlgorithm("RSA");
-      keys.loadKeys();
+      keys.loadKeys(null);
     }
     return keys;
   }
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 53f1ce9..c38f5fa 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static org.junit.Assert.fail;
@@ -29,12 +29,12 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.git.DelegateSystemReader;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import com.google.inject.Module;
@@ -67,10 +67,10 @@
     private ServerContext(GerritServer server) throws Exception {
       this.server = server;
       Injector i = server.getTestInjector();
-      if (adminId == null) {
-        adminId = i.getInstance(AccountCreator.class).admin().id();
+      if (admin == null) {
+        admin = i.getInstance(AccountCreator.class).admin();
       }
-      ctx = i.getInstance(OneOffRequestContext.class).openAs(adminId);
+      ctx = i.getInstance(OneOffRequestContext.class).openAs(admin.id());
       GerritApi gApi = i.getInstance(GerritApi.class);
 
       try {
@@ -125,7 +125,7 @@
   @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
 
   protected SitePaths sitePaths;
-  protected Account.Id adminId;
+  protected TestAccount admin;
 
   private GerritServer.Description serverDesc;
   private SystemReader oldSystemReader;
@@ -143,20 +143,10 @@
   private static SystemReader setFakeSystemReader(File tempDir) {
     SystemReader oldSystemReader = SystemReader.getInstance();
     SystemReader.setInstance(
-        new SystemReader() {
+        new DelegateSystemReader(oldSystemReader) {
           @Override
-          public String getHostname() {
-            return oldSystemReader.getHostname();
-          }
-
-          @Override
-          public String getenv(String variable) {
-            return oldSystemReader.getenv(variable);
-          }
-
-          @Override
-          public String getProperty(String key) {
-            return oldSystemReader.getProperty(key);
+          public FileBasedConfig openJGitConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "jgit.config"), FS.detect());
           }
 
           @Override
@@ -168,16 +158,6 @@
           public FileBasedConfig openSystemConfig(Config parent, FS fs) {
             return new FileBasedConfig(parent, new File(tempDir, "system.config"), FS.detect());
           }
-
-          @Override
-          public long getCurrentTime() {
-            return oldSystemReader.getCurrentTime();
-          }
-
-          @Override
-          public int getTimezone(long when) {
-            return oldSystemReader.getTimezone(when);
-          }
         });
     return oldSystemReader;
   }
@@ -214,8 +194,8 @@
     // Use invokeProgram with the current classloader, rather than mainImpl, which would create a
     // new classloader. This is necessary so that static state, particularly the SystemReader, is
     // shared with the test method.
-    assertThat(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
-        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+    assertWithMessage("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+        .that(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
         .isEqualTo(0);
   }
 
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index c937aed..07bb739 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -21,8 +21,8 @@
 import com.google.common.collect.Streams;
 import com.google.common.net.InetAddresses;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
 import org.apache.http.client.utils.URIBuilder;
diff --git a/java/com/google/gerrit/acceptance/UseClockStep.java b/java/com/google/gerrit/acceptance/UseClockStep.java
new file mode 100644
index 0000000..10a93fe
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/UseClockStep.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Annotation to use a clock step for the execution of acceptance tests (the test class must inherit
+ * from {@link AbstractDaemonTest}).
+ *
+ * <p>Annotations on method level override annotations on class level.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface UseClockStep {
+  /** Amount to increment clock by on each lookup. */
+  long clockStep() default 1L;
+
+  /** Time unit for {@link #clockStep()}. */
+  TimeUnit clockStepUnit() default TimeUnit.SECONDS;
+
+  /** Whether the clock should initially be set to {@link java.time.Instant#EPOCH}. */
+  boolean startAtEpoch() default false;
+}
diff --git a/java/com/google/gerrit/acceptance/UseSystemTime.java b/java/com/google/gerrit/acceptance/UseSystemTime.java
new file mode 100644
index 0000000..e9cbd47
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/UseSystemTime.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation to use the system time for the execution of acceptance tests (the test class must
+ * inherit from {@link AbstractDaemonTest}).
+ *
+ * <p>Can only be applied on method level, since using system time on class level is the default if
+ * {@link UseClockStep} is not used.
+ *
+ * <p>Intended to be used to use system time for single tests when the test class is annotated with
+ * {@link UseClockStep}.
+ */
+@Target(METHOD)
+@Retention(RUNTIME)
+public @interface UseSystemTime {}
diff --git a/java/com/google/gerrit/acceptance/UseTimezone.java b/java/com/google/gerrit/acceptance/UseTimezone.java
new file mode 100644
index 0000000..7412030
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/UseTimezone.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation to set a timezone for the execution of acceptance tests (the test class must inherit
+ * from {@link AbstractDaemonTest}).
+ *
+ * <p>Annotations on method level override annotations on class level.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface UseTimezone {
+  /** The timezone that should be used for the test, e.g. "US/Eastern". */
+  String timezone();
+}
diff --git a/java/com/google/gerrit/acceptance/rest/CreateTestPlugin.java b/java/com/google/gerrit/acceptance/rest/CreateTestPlugin.java
index 85a2d7b..2a77d31 100644
--- a/java/com/google/gerrit/acceptance/rest/CreateTestPlugin.java
+++ b/java/com/google/gerrit/acceptance/rest/CreateTestPlugin.java
@@ -33,7 +33,8 @@
   }
 
   @Override
-  public Object apply(ConfigResource parentResource, IdString id, Input input) throws Exception {
+  public Response<?> apply(ConfigResource parentResource, IdString id, Input input)
+      throws Exception {
     return Response.created(input);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/rest/GetTestPlugin.java b/java/com/google/gerrit/acceptance/rest/GetTestPlugin.java
index a31cc15..3b6da85 100644
--- a/java/com/google/gerrit/acceptance/rest/GetTestPlugin.java
+++ b/java/com/google/gerrit/acceptance/rest/GetTestPlugin.java
@@ -27,7 +27,7 @@
 public class GetTestPlugin implements RestReadView<PluginResource> {
 
   @Override
-  public Object apply(PluginResource resource)
+  public Response<?> apply(PluginResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
     return Response.ok("Foo");
   }
diff --git a/java/com/google/gerrit/acceptance/rest/ListTestPlugin.java b/java/com/google/gerrit/acceptance/rest/ListTestPlugin.java
index 00e071c..ce037a7 100644
--- a/java/com/google/gerrit/acceptance/rest/ListTestPlugin.java
+++ b/java/com/google/gerrit/acceptance/rest/ListTestPlugin.java
@@ -18,14 +18,15 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 
 public class ListTestPlugin implements RestReadView<ConfigResource> {
 
   @Override
-  public Object apply(ConfigResource resource)
+  public Response<?> apply(ConfigResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    return ImmutableList.of();
+    return Response.ok(ImmutableList.of());
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index 61b828e..efae223 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.account;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 
 /**
  * An aggregation of operations on accounts for test purposes.
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 7641e47..f1b840a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
@@ -61,14 +61,14 @@
   private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
         (account, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, account.getAccount().getId());
+            fillBuilder(updateBuilder, accountCreation, account.account().id());
     AccountState createdAccount = createAccount(accountUpdater);
-    return createdAccount.getAccount().getId();
+    return createdAccount.account().id();
   }
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
       throws IOException, ConfigInvalidException {
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
   }
 
@@ -129,13 +129,13 @@
     }
 
     private TestAccount toTestAccount(AccountState accountState) {
-      Account account = accountState.getAccount();
+      Account account = accountState.account();
       return TestAccount.builder()
-          .accountId(account.getId())
-          .preferredEmail(Optional.ofNullable(account.getPreferredEmail()))
-          .fullname(Optional.ofNullable(account.getFullName()))
-          .username(accountState.getUserName())
-          .active(accountState.getAccount().isActive())
+          .accountId(account.id())
+          .preferredEmail(Optional.ofNullable(account.preferredEmail()))
+          .fullname(Optional.ofNullable(account.fullName()))
+          .username(accountState.userName())
+          .active(accountState.account().isActive())
           .build();
     }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
index e7ffeec..2574d55 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.Optional;
 
 @AutoValue
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index f2414e0..983fec0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.Optional;
 
 @AutoValue
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 4847fdb..6c95360 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -19,7 +19,7 @@
 
 import com.google.gerrit.acceptance.SshEnabled;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
index 533d06b..b9414e1 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.group;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 /**
  * An aggregation of operations on groups for test purposes.
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index e0ddee5..fd5c003 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUUID;
@@ -78,10 +78,10 @@
   }
 
   private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation) {
-    AccountGroup.Id groupId = new AccountGroup.Id(seq.nextGroupId());
+    AccountGroup.Id groupId = AccountGroup.id(seq.nextGroupId());
     String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
     AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
-    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(groupName);
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(groupName);
     return InternalGroupCreation.builder()
         .setId(groupId)
         .setGroupUUID(groupUuid)
@@ -153,7 +153,7 @@
 
     private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
       InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
-      groupUpdate.name().map(AccountGroup.NameKey::new).ifPresent(builder::setName);
+      groupUpdate.name().map(AccountGroup::nameKey).ifPresent(builder::setName);
       groupUpdate.description().ifPresent(builder::setDescription);
       groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
       groupUpdate.visibleToAll().ifPresent(builder::setVisibleToAll);
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
index b450304..c885353 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -16,8 +16,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 import java.util.Optional;
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
index 612ce2a..8bb7b23 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -18,8 +18,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Optional;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
index bc9d569..47c7117 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
@@ -18,8 +18,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
new file mode 100644
index 0000000..8a3a23a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -0,0 +1,22 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_testonly = 1)
+
+java_library(
+    name = "project",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:lang",
+        "//lib/guice",
+    ],
+)
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
index 029d161..2db611b 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.project.ProjectConfig;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -28,6 +30,9 @@
 
   PerProjectOperations project(Project.NameKey key);
 
+  /** Starts a fluent chain for updating All-Projects. */
+  TestProjectUpdate.Builder allProjectsForUpdate();
+
   interface PerProjectOperations {
     /**
      * Returns the commit for this project. branchName can either be shortened ("HEAD", "master") or
@@ -40,5 +45,33 @@
      * fully qualified refname ("refs/heads/master").
      */
     boolean hasHead(String branchName);
+
+    /** Returns a fresh {@link ProjectConfig} read from the tip of {@code refs/meta/config}. */
+    ProjectConfig getProjectConfig();
+
+    /**
+     * Returns a fresh JGit {@link Config} instance read from {@code project.config} at the tip of
+     * {@code refs/meta/config}. Does not have a base config, i.e. does not respect {@code
+     * $site_path/etc/project.config}.
+     */
+    Config getConfig();
+
+    /**
+     * Starts the fluent chain to update a project. The returned builder can be used to specify how
+     * the attributes of the project should be modified. To update the project for real, the {@link
+     * TestProjectUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * projectOperations
+     *     .forUpdate()
+     *     .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+     *     .update();
+     * </pre>
+     *
+     * @return a builder to update the check.
+     */
+    TestProjectUpdate.Builder forUpdate();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 28be3f3..7797fe0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,30 +14,68 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
-import com.google.common.base.Preconditions;
+import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCreator;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import org.apache.commons.lang.RandomStringUtils;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
 
 public class ProjectOperationsImpl implements ProjectOperations {
-  private final ProjectCreator projectCreator;
+  private final AllProjectsName allProjectsName;
   private final GitRepositoryManager repoManager;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCreator projectCreator;
 
   @Inject
-  ProjectOperationsImpl(GitRepositoryManager repoManager, ProjectCreator projectCreator) {
+  ProjectOperationsImpl(
+      AllProjectsName allProjectsName,
+      GitRepositoryManager repoManager,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCreator projectCreator) {
+    this.allProjectsName = allProjectsName;
     this.repoManager = repoManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
     this.projectCreator = projectCreator;
   }
 
@@ -58,7 +96,7 @@
     args.ownerIds = new ArrayList<>();
     projectCreation.submitType().ifPresent(st -> args.submitType = st);
     projectCreator.createProject(args);
-    return new Project.NameKey(name);
+    return Project.nameKey(name);
   }
 
   @Override
@@ -66,8 +104,12 @@
     return new PerProjectOperations(key);
   }
 
-  private class PerProjectOperations implements ProjectOperations.PerProjectOperations {
+  @Override
+  public TestProjectUpdate.Builder allProjectsForUpdate() {
+    return project(allProjectsName).forUpdate();
+  }
 
+  private class PerProjectOperations implements ProjectOperations.PerProjectOperations {
     Project.NameKey nameKey;
 
     PerProjectOperations(Project.NameKey nameKey) {
@@ -76,7 +118,7 @@
 
     @Override
     public RevCommit getHead(String branch) {
-      return Preconditions.checkNotNull(headOrNull(branch));
+      return requireNonNull(headOrNull(branch));
     }
 
     @Override
@@ -84,6 +126,88 @@
       return headOrNull(branch) != null;
     }
 
+    @Override
+    public TestProjectUpdate.Builder forUpdate() {
+      return TestProjectUpdate.builder(nameKey, allProjectsName, this::updateProject);
+    }
+
+    private void updateProject(TestProjectUpdate projectUpdate)
+        throws IOException, ConfigInvalidException {
+      try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+        removePermissions(projectConfig, projectUpdate.removedPermissions());
+        addCapabilities(projectConfig, projectUpdate.addedCapabilities());
+        addPermissions(projectConfig, projectUpdate.addedPermissions());
+        addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+        setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
+        projectConfig.commit(metaDataUpdate);
+      }
+      projectCache.evict(nameKey);
+    }
+
+    private void removePermissions(
+        ProjectConfig projectConfig,
+        ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
+      for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
+        Permission permission =
+            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
+        if (p.group().isPresent()) {
+          GroupReference group = new GroupReference(p.group().get(), p.group().get().get());
+          group = projectConfig.resolve(group);
+          permission.removeRule(group);
+        } else {
+          permission.clearRules();
+        }
+      }
+    }
+
+    private void addCapabilities(
+        ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
+      for (TestCapability c : addedCapabilities) {
+        PermissionRule rule = newRule(projectConfig, c.group());
+        rule.setRange(c.min(), c.max());
+        projectConfig
+            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+            .getPermission(c.name(), true)
+            .add(rule);
+      }
+    }
+
+    private void addPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
+      for (TestPermission p : addedPermissions) {
+        PermissionRule rule = newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setForce(p.force());
+        projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
+      }
+    }
+
+    private void addLabelPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
+      for (TestLabelPermission p : addedLabelPermissions) {
+        PermissionRule rule = newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setRange(p.min(), p.max());
+        String permissionName =
+            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        Permission permission =
+            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
+        permission.add(rule);
+      }
+    }
+
+    private void setExclusiveGroupPermissions(
+        ProjectConfig projectConfig,
+        ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
+      exclusiveGroupPermissions.forEach(
+          (key, exclusive) ->
+              projectConfig
+                  .getAccessSection(key.section(), true)
+                  .getPermission(key.name(), true)
+                  .setExclusiveGroup(exclusive));
+    }
+
     private RevCommit headOrNull(String branch) {
       if (!branch.startsWith(Constants.R_REFS)) {
         branch = RefNames.REFS_HEADS + branch;
@@ -97,5 +221,45 @@
         throw new IllegalStateException(e);
       }
     }
+
+    @Override
+    public ProjectConfig getProjectConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.create(nameKey);
+        projectConfig.load(nameKey, repo);
+        return projectConfig;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Override
+    public Config getConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey);
+          RevWalk rw = new RevWalk(repo)) {
+        Ref ref = repo.exactRef(REFS_CONFIG);
+        if (ref == null) {
+          return new Config();
+        }
+        RevTree tree = rw.parseTree(ref.getObjectId());
+        TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), PROJECT_CONFIG, tree);
+        if (tw == null) {
+          return new Config();
+        }
+        ObjectLoader loader = rw.getObjectReader().open(tw.getObjectId(0));
+        String text = new String(loader.getCachedBytes(), UTF_8);
+        Config config = new Config();
+        config.fromText(text);
+        return config;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+  }
+
+  private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+    group = project.resolve(group);
+    return new PermissionRule(group);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 31af1d2..99e045c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -16,8 +16,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
 import java.util.Optional;
 
 @AutoValue
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
new file mode 100644
index 0000000..734854b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -0,0 +1,436 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+
+@AutoValue
+public abstract class TestProjectUpdate {
+  /** Starts a builder for allowing a capability. */
+  public static TestCapability.Builder allowCapability(String name) {
+    return TestCapability.builder().name(name);
+  }
+
+  /** Records a global capability to be updated. */
+  @AutoValue
+  public abstract static class TestCapability {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestCapability.Builder();
+    }
+
+    abstract String name();
+
+    abstract AccountGroup.UUID group();
+
+    abstract int min();
+
+    abstract int max();
+
+    /** Builder for {@link TestCapability}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      /** Sets the name of the capability. */
+      public abstract Builder name(String name);
+
+      abstract String name();
+
+      /** Sets the group to which the capability applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder min(int min);
+
+      abstract Optional<Integer> min();
+
+      abstract Builder max(int max);
+
+      abstract Optional<Integer> max();
+
+      /** Sets the minimum and maximum values for the capability. */
+      public Builder range(int min, int max) {
+        checkNonInvertedRange(min, max);
+        return min(min).max(max);
+      }
+
+      /** Builds the {@link TestCapability}. */
+      abstract TestCapability autoBuild();
+
+      public TestCapability build() {
+        PermissionRange.WithDefaults withDefaults = GlobalCapability.getRange(name());
+        if (withDefaults != null) {
+          int min = min().orElse(withDefaults.getDefaultMin());
+          int max = max().orElse(withDefaults.getDefaultMax());
+          range(min, max);
+          // Don't enforce range is nonempty; this is allowed for e.g. batchChangesLimit.
+        } else {
+          checkArgument(
+              !min().isPresent() && !max().isPresent(),
+              "capability %s does not support ranges",
+              name());
+          range(0, 0);
+        }
+
+        return autoBuild();
+      }
+    }
+  }
+
+  /** Starts a builder for allowing a permission. */
+  public static TestPermission.Builder allow(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a permission. */
+  public static TestPermission.Builder deny(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.DENY);
+  }
+
+  /** Starts a builder for blocking a permission. */
+  public static TestPermission.Builder block(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /**
+   * Records a permission to be updated.
+   *
+   * <p>Not used for permissions that have ranges (label permissions) or global capabilities.
+   */
+  @AutoValue
+  public abstract static class TestPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermission.Builder().force(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract boolean force();
+
+    /** Builder for {@link TestPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID groupUuid);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      /** Sets whether the permission is a force permission. */
+      public abstract Builder force(boolean force);
+
+      /** Builds the {@link TestPermission}. */
+      public abstract TestPermission build();
+    }
+  }
+
+  /** Starts a builder for allowing a label permission. */
+  public static TestLabelPermission.Builder allowLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a label permission. */
+  public static TestLabelPermission.Builder blockLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Records a label permission to be updated. */
+  @AutoValue
+  public abstract static class TestLabelPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestLabelPermission.Builder().impersonation(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract int min();
+
+    abstract int max();
+
+    abstract boolean impersonation();
+
+    /** Builder for {@link TestLabelPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      abstract Builder min(int min);
+
+      abstract Builder max(int max);
+
+      /** Sets the minimum and maximum values for the permission. */
+      public Builder range(int min, int max) {
+        checkArgument(min != 0 || max != 0, "empty range");
+        checkNonInvertedRange(min, max);
+        return min(min).max(max);
+      }
+
+      /** Sets whether this permission should be for impersonating another user's votes. */
+      public abstract Builder impersonation(boolean impersonation);
+
+      abstract TestLabelPermission autoBuild();
+
+      /** Builds the {@link TestPermission}. */
+      public TestLabelPermission build() {
+        TestLabelPermission result = autoBuild();
+        checkLabelName(result.name());
+        return result;
+      }
+    }
+  }
+
+  /**
+   * Starts a builder for describing a permission key for deletion. Not for label permissions or
+   * global capabilities.
+   */
+  public static TestPermissionKey.Builder permissionKey(String name) {
+    return TestPermissionKey.builder().name(name);
+  }
+
+  /** Starts a builder for describing a label permission key for deletion. */
+  public static TestPermissionKey.Builder labelPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forLabel(name));
+  }
+
+  /** Starts a builder for describing a capability key for deletion. */
+  public static TestPermissionKey.Builder capabilityKey(String name) {
+    return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
+  }
+
+  /** Records the key of a permission (of any type) for deletion. */
+  @AutoValue
+  public abstract static class TestPermissionKey {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermissionKey.Builder();
+    }
+
+    abstract String section();
+
+    abstract String name();
+
+    abstract Optional<AccountGroup.UUID> group();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder section(String section);
+
+      abstract Optional<String> section();
+
+      /** Sets the ref pattern used on the permission. Not for global capabilities. */
+      public Builder ref(String ref) {
+        requireNonNull(ref);
+        checkArgument(ref.startsWith(Constants.R_REFS), "must be a ref: %s", ref);
+        checkArgument(
+            !section().isPresent() || !section().get().equals(GLOBAL_CAPABILITIES),
+            "can't set ref on global capability");
+        return section(ref);
+      }
+
+      abstract Builder name(String name);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      /** Builds the {@link TestPermissionKey}. */
+      public abstract TestPermissionKey build();
+    }
+  }
+
+  static Builder builder(
+      Project.NameKey nameKey,
+      AllProjectsName allProjectsName,
+      ThrowingConsumer<TestProjectUpdate> projectUpdater) {
+    return new AutoValue_TestProjectUpdate.Builder()
+        .nameKey(nameKey)
+        .allProjectsName(allProjectsName)
+        .projectUpdater(projectUpdater);
+  }
+
+  /** Builder for {@link TestProjectUpdate}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    abstract Builder nameKey(Project.NameKey project);
+
+    abstract Builder allProjectsName(AllProjectsName allProjects);
+
+    abstract ImmutableList.Builder<TestPermission> addedPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestLabelPermission> addedLabelPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestCapability> addedCapabilitiesBuilder();
+
+    abstract ImmutableList.Builder<TestPermissionKey> removedPermissionsBuilder();
+
+    abstract ImmutableMap.Builder<TestPermissionKey, Boolean> exclusiveGroupPermissionsBuilder();
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission testPermission) {
+      addedPermissionsBuilder().add(testPermission);
+      return this;
+    }
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission.Builder testPermissionBuilder) {
+      return add(testPermissionBuilder.build());
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission testLabelPermission) {
+      addedLabelPermissionsBuilder().add(testLabelPermission);
+      return this;
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission.Builder testLabelPermissionBuilder) {
+      return add(testLabelPermissionBuilder.build());
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability testCapability) {
+      addedCapabilitiesBuilder().add(testCapability);
+      return this;
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability.Builder testCapabilityBuilder) {
+      return add(testCapabilityBuilder.build());
+    }
+
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey testPermissionKey) {
+      removedPermissionsBuilder().add(testPermissionKey);
+      return this;
+    }
+
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey.Builder testPermissionKeyBuilder) {
+      return remove(testPermissionKeyBuilder.build());
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(
+        TestPermissionKey.Builder testPermissionKeyBuilder, boolean exclusive) {
+      return setExclusiveGroup(testPermissionKeyBuilder.build(), exclusive);
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(TestPermissionKey testPermissionKey, boolean exclusive) {
+      checkArgument(
+          !testPermissionKey.group().isPresent(),
+          "do not specify group for setExclusiveGroup: %s",
+          testPermissionKey);
+      checkArgument(
+          !testPermissionKey.section().equals(GLOBAL_CAPABILITIES),
+          "setExclusiveGroup not valid for global capabilities: %s",
+          testPermissionKey);
+      exclusiveGroupPermissionsBuilder().put(testPermissionKey, exclusive);
+      return this;
+    }
+
+    abstract Builder projectUpdater(ThrowingConsumer<TestProjectUpdate> projectUpdater);
+
+    abstract TestProjectUpdate autoBuild();
+
+    TestProjectUpdate build() {
+      TestProjectUpdate projectUpdate = autoBuild();
+      if (projectUpdate.hasCapabilityUpdates()) {
+        checkArgument(
+            projectUpdate.nameKey().equals(projectUpdate.allProjectsName()),
+            "cannot update global capabilities on %s, only %s: %s",
+            projectUpdate.nameKey(),
+            projectUpdate.allProjectsName(),
+            projectUpdate);
+      }
+      return projectUpdate;
+    }
+
+    /** Executes the update, updating the underlying project. */
+    public void update() {
+      TestProjectUpdate projectUpdate = build();
+      projectUpdate.projectUpdater().acceptAndThrowSilently(projectUpdate);
+    }
+  }
+
+  abstract Project.NameKey nameKey();
+
+  abstract AllProjectsName allProjectsName();
+
+  abstract ImmutableList<TestPermission> addedPermissions();
+
+  abstract ImmutableList<TestLabelPermission> addedLabelPermissions();
+
+  abstract ImmutableList<TestCapability> addedCapabilities();
+
+  abstract ImmutableList<TestPermissionKey> removedPermissions();
+
+  abstract ImmutableMap<TestPermissionKey, Boolean> exclusiveGroupPermissions();
+
+  abstract ThrowingConsumer<TestProjectUpdate> projectUpdater();
+
+  boolean hasCapabilityUpdates() {
+    return !addedCapabilities().isEmpty()
+        || removedPermissions().stream().anyMatch(k -> k.section().equals(GLOBAL_CAPABILITIES));
+  }
+
+  private static void checkLabelName(String name) {
+    // "label-Code-Review" is technically a valid label name, and we don't prevent users from
+    // using it in production, but specifying it in a test is programmer error.
+    checkArgument(!Permission.isLabel(name), "expected label name, got permission name: %s", name);
+    LabelType.checkName(name);
+  }
+
+  private static void checkNonInvertedRange(int min, int max) {
+    checkArgument(min <= max, "inverted range: %s > %s", min, max);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
index 17d9294..a9914b3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 
 /**
  * An aggregation of operations on Guice request scopes for test purposes.
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
index 5546422..db730a6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 717f585..1099919 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -21,16 +21,15 @@
     visibility = ["//visibility:public"],
     deps = [
         ":annotations",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/prettify:server",
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gwtorm",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
 
diff --git a/java/com/google/gerrit/common/FileUtil.java b/java/com/google/gerrit/common/FileUtil.java
index 04288bc..5b0925e 100644
--- a/java/com/google/gerrit/common/FileUtil.java
+++ b/java/com/google/gerrit/common/FileUtil.java
@@ -44,7 +44,6 @@
   }
 
   public static void chmod(int mode, Path path) {
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
     chmod(mode, path.toFile());
   }
 
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
index 2243eab..38de5b1 100644
--- a/java/com/google/gerrit/common/PageLinks.java
+++ b/java/com/google/gerrit/common/PageLinks.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.common;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.client.KeyUtil;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
+import com.google.gerrit.entities.KeyUtil;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 
 public class PageLinks {
   public static final String PROJECT_CHANGE_DELIMITER = "/+/";
@@ -83,7 +83,7 @@
   }
 
   public static String toChange(@Nullable Project.NameKey project, PatchSet.Id ps) {
-    return toChange(project, ps.getParentKey()) + ps.getId();
+    return toChange(project, ps.changeId()) + ps.getId();
   }
 
   public static String toProject(Project.NameKey p) {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 44ed92b..9f8b255 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
@@ -22,13 +23,13 @@
 import java.lang.annotation.Target;
 
 /**
- * A marker for a method that is public solely because it is called from inside a project or an
- * organisation using Gerrit.
+ * A marker to say a method/type/field is added or is increased to public solely because it is
+ * called from inside a project or an organisation using Gerrit.
  */
-@Target({METHOD, TYPE})
+@Target({METHOD, TYPE, FIELD})
 @Retention(RUNTIME)
 public @interface UsedAt {
-  /** Enumeration of projects that call a method that would otherwise be private. */
+  /** Enumeration of projects that call a method/type/field. */
   enum Project {
     GOOGLE,
     COLLABNET,
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 3670e96..0c9663b 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index 1ae246f..d69f0bb 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -42,7 +42,7 @@
   protected CommentDetail() {}
 
   public void include(Change.Id changeId, Comment p) {
-    PatchSet.Id psId = new PatchSet.Id(changeId, p.key.patchSetId);
+    PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
         a.add(p);
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
index a6e8cdd..bc106f0 100644
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ b/java/com/google/gerrit/common/data/ContributorAgreement.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index e0a6569..ebf423c 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.entities.Patch;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.HashSet;
diff --git a/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
index a6c534c..5ed0158 100644
--- a/java/com/google/gerrit/common/data/GarbageCollectionResult.java
+++ b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/common/data/GroupDescription.java b/java/com/google/gerrit/common/data/GroupDescription.java
index d22b94b..ed8b39d 100644
--- a/java/com/google/gerrit/common/data/GroupDescription.java
+++ b/java/com/google/gerrit/common/data/GroupDescription.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
index e5b0965..0af088e 100644
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ b/java/com/google/gerrit/common/data/GroupReference.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 /** Describes a group within a projects {@link AccessSection}s. */
 public class GroupReference implements Comparable<GroupReference> {
@@ -46,17 +48,27 @@
   /**
    * Create a group reference.
    *
-   * @param uuid UUID of the group, may be {@code null} if the group name couldn't be resolved
+   * @param uuid UUID of the group, must not be {@code null}
    * @param name the group name, must not be {@code null}
    */
-  public GroupReference(@Nullable AccountGroup.UUID uuid, String name) {
-    setUUID(uuid);
+  public GroupReference(AccountGroup.UUID uuid, String name) {
+    setUUID(requireNonNull(uuid));
+    setName(name);
+  }
+
+  /**
+   * Create a group reference where the group's name couldn't be resolved.
+   *
+   * @param name the group name, must not be {@code null}
+   */
+  public GroupReference(String name) {
+    setUUID(null);
     setName(name);
   }
 
   @Nullable
   public AccountGroup.UUID getUUID() {
-    return uuid != null ? new AccountGroup.UUID(uuid) : null;
+    return uuid != null ? AccountGroup.uuid(uuid) : null;
   }
 
   public void setUUID(@Nullable AccountGroup.UUID newUUID) {
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
index 7d13c70..6af675b 100644
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ b/java/com/google/gerrit/common/data/LabelFunction.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -98,18 +98,18 @@
     }
 
     for (PatchSetApproval a : approvals) {
-      if (a.getValue() == 0) {
+      if (a.value() == 0) {
         continue;
       }
 
       if (isBlock && labelType.isMaxNegative(a)) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
         submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
         return submitRecordLabel;
       }
 
       if (labelType.isMaxPositive(a) || !requiresMaxValue) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
 
         submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
         if (isRequired) {
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 42945c4..90b0930 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -19,8 +19,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -34,6 +34,7 @@
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
+  public static final boolean DEF_COPY_ANY_SCORE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
@@ -96,6 +97,7 @@
 
   protected LabelFunction function;
 
+  protected boolean copyAnyScore;
   protected boolean copyMinScore;
   protected boolean copyMaxScore;
   protected boolean copyAllScoresOnMergeFirstParentUpdate;
@@ -139,6 +141,7 @@
     setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
     setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
     setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setCopyAnyScore(DEF_COPY_ANY_SCORE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
@@ -155,7 +158,7 @@
   }
 
   public boolean matches(PatchSetApproval psa) {
-    return psa.getLabelId().get().equalsIgnoreCase(name);
+    return psa.labelId().get().equalsIgnoreCase(name);
   }
 
   public LabelFunction getFunction() {
@@ -229,6 +232,14 @@
     this.defaultValue = defaultValue;
   }
 
+  public boolean isCopyAnyScore() {
+    return copyAnyScore;
+  }
+
+  public void setCopyAnyScore(boolean copyAnyScore) {
+    this.copyAnyScore = copyAnyScore;
+  }
+
   public boolean isCopyMinScore() {
     return copyMinScore;
   }
@@ -279,11 +290,11 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return maxNegative == ca.getValue();
+    return maxNegative == ca.value();
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.getValue();
+    return maxPositive == ca.value();
   }
 
   public LabelValue getValue(short value) {
@@ -291,11 +302,11 @@
   }
 
   public LabelValue getValue(PatchSetApproval ca) {
-    return byValue.get(ca.getValue());
+    return byValue.get(ca.value());
   }
 
   public LabelId getLabelId() {
-    return new LabelId(name);
+    return LabelId.create(name);
   }
 
   @Override
diff --git a/java/com/google/gerrit/common/data/LabelTypes.java b/java/com/google/gerrit/common/data/LabelTypes.java
index d5891d1..1647658 100644
--- a/java/com/google/gerrit/common/data/LabelTypes.java
+++ b/java/com/google/gerrit/common/data/LabelTypes.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.entities.LabelId;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index 3428580..c177e35 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
@@ -56,7 +56,6 @@
   private CommentDetail comments;
   private List<Patch> history;
   private boolean hugeFile;
-  private boolean intralineDifference;
   private boolean intralineFailure;
   private boolean intralineTimeout;
   private boolean binary;
@@ -83,7 +82,6 @@
       CommentDetail cd,
       List<Patch> hist,
       boolean hf,
-      boolean id,
       boolean idf,
       boolean idt,
       boolean bin,
@@ -108,7 +106,6 @@
     comments = cd;
     history = hist;
     hugeFile = hf;
-    intralineDifference = id;
     intralineFailure = idf;
     intralineTimeout = idt;
     binary = bin;
@@ -178,10 +175,6 @@
     return diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE;
   }
 
-  public boolean hasIntralineDifference() {
-    return intralineDifference;
-  }
-
   public boolean hasIntralineFailure() {
     return intralineFailure;
   }
diff --git a/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
index 2f05854..97c3731 100644
--- a/java/com/google/gerrit/common/data/PermissionRange.java
+++ b/java/com/google/gerrit/common/data/PermissionRange.java
@@ -17,6 +17,10 @@
 import java.util.ArrayList;
 import java.util.List;
 
+/**
+ * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
+ * the empty range.
+ */
 public class PermissionRange implements Comparable<PermissionRange> {
   public static class WithDefaults extends PermissionRange {
     protected int defaultMin;
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
index 22861b2..fe5843ad 100644
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
index aaf0798..6ac4695 100644
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ b/java/com/google/gerrit/common/data/SubscribeSection.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -60,14 +60,14 @@
    * @param branch the branch to check
    * @return if the branch could trigger a superproject update
    */
-  public boolean appliesTo(Branch.NameKey branch) {
+  public boolean appliesTo(BranchNameKey branch) {
     for (RefSpec r : matchingRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
     for (RefSpec r : multiMatchRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index 8ab01de..b9ec30b 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -7,7 +7,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index 265d590..d841aa6 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -21,9 +21,9 @@
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
-public class GroupReferenceSubject extends Subject<GroupReferenceSubject, GroupReference> {
+public class GroupReferenceSubject extends Subject {
 
   public static GroupReferenceSubject assertThat(GroupReference group) {
     return assertAbout(groupReferences()).that(group);
@@ -33,19 +33,20 @@
     return GroupReferenceSubject::new;
   }
 
+  private final GroupReference group;
+
   private GroupReferenceSubject(FailureMetadata metadata, GroupReference group) {
     super(metadata, group);
+    this.group = group;
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
+  public ComparableSubject<AccountGroup.UUID> groupUuid() {
     isNotNull();
-    GroupReference group = actual();
-    return check("groupUuid()").that(group.getUUID());
+    return check("getUUID()").that(group.getUUID());
   }
 
   public StringSubject name() {
     isNotNull();
-    GroupReference group = actual();
-    return check("name()").that(group.getName());
+    return check("getName()").that(group.getName());
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 8ed9729..0d1f9cd 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.elasticsearch.builders.QueryBuilder;
 import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
 import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
@@ -45,7 +46,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gson.Gson;
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index a9b145b..edbd82c 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
@@ -13,10 +14,10 @@
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:gson",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
         "//lib/commons:codec",
         "//lib/commons:lang",
@@ -29,6 +30,5 @@
         "//lib/httpcomponents:httpcore",
         "//lib/httpcomponents:httpcore-nio",
         "//lib/jackson:jackson-core",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index f646efc..374120e 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.gerrit.server.index.account.AccountField.ID;
-
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.SitePaths;
@@ -83,15 +81,23 @@
       throw new StorageException(
           String.format(
               "Failed to replace account %s in index %s: %s",
-              as.getAccount().getId(), indexName, statusCode));
+              as.account().id(), indexName, statusCode));
     }
   }
 
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    JsonArray sortArray = getSortArray(AccountField.ID.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::accountFields), type, sortArray);
+    JsonArray sortArray =
+        getSortArray(
+            schema.useLegacyNumericFields()
+                ? AccountField.ID.getName()
+                : AccountField.ID_STR.getName());
+    return new ElasticQuerySource(
+        p,
+        opts.filterFields(o -> IndexUtils.accountFields(o, schema.useLegacyNumericFields())),
+        type,
+        sortArray);
   }
 
   @Override
@@ -106,7 +112,7 @@
 
   @Override
   protected String getId(AccountState as) {
-    return as.getAccount().getId().toString();
+    return as.account().id().toString();
   }
 
   @Override
@@ -116,7 +122,15 @@
       source = json.getAsJsonObject().get("fields");
     }
 
-    Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+    Account.Id id =
+        Account.id(
+            source
+                .getAsJsonObject()
+                .get(
+                    schema.useLegacyNumericFields()
+                        ? AccountField.ID.getName()
+                        : AccountField.ID_STR.getName())
+                .getAsInt());
     // Use the AccountCache rather than depending on any stored fields in the document (of which
     // there shouldn't be any). The most expensive part to compute anyway is the effective group
     // IDs, and we don't have a good way to reindex when those change.
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index ca2b1a8..a658400 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -30,18 +30,19 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -87,6 +88,7 @@
   private final ChangeMapping mapping;
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
+  private final FieldDef<ChangeData, ?> idField;
 
   @Inject
   ElasticChangeIndex(
@@ -98,7 +100,9 @@
     super(cfg, sitePaths, schema, clientBuilder, CHANGES);
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
-    mapping = new ChangeMapping(schema, client.adapter());
+    this.mapping = new ChangeMapping(schema, client.adapter());
+    this.idField =
+        this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
   }
 
   @Override
@@ -136,7 +140,8 @@
       }
     }
 
-    QueryOptions filteredOpts = opts.filterFields(IndexUtils::changeFields);
+    QueryOptions filteredOpts =
+        opts.filterFields(o -> IndexUtils.changeFields(o, schema.useLegacyNumericFields()));
     return new ElasticQuerySource(p, filteredOpts, getURI(indexes), getSortArray());
   }
 
@@ -146,7 +151,7 @@
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
-    addNamedElement(ChangeField.LEGACY_ID.getName(), properties, sortArray);
+    addNamedElement(idField.getName(), properties, sortArray);
     return sortArray;
   }
 
@@ -179,10 +184,10 @@
     JsonElement c = source.get(ChangeField.CHANGE.getName());
 
     if (c == null) {
-      int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+      int id = source.get(idField.getName()).getAsInt();
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(new Project.NameKey(projectName), new Change.Id(id));
+      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
     }
 
     ChangeData cd =
@@ -252,7 +257,7 @@
           if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
             break;
           }
-          accounts.add(new Account.Id(aId));
+          accounts.add(Account.id(aId));
         }
         cd.setReviewedBy(accounts);
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index eff3d52..3922f89 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -18,13 +18,13 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.group.InternalGroup;
@@ -116,8 +116,7 @@
     }
 
     AccountGroup.UUID uuid =
-        new AccountGroup.UUID(
-            source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+        AccountGroup.uuid(source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index a0ebb07..7e45f4f 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -27,7 +28,6 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.project.ProjectCache;
@@ -117,8 +117,7 @@
     }
 
     Project.NameKey nameKey =
-        new Project.NameKey(
-            source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
     return projectCache.get().get(nameKey).toProjectData();
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index 394158d..d05e91c 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -145,7 +145,7 @@
     String name = p.getField().getName();
     String value = p.getValue();
 
-    if (value.isEmpty()) {
+    if (!p.getField().isRepeatable() && value.isEmpty()) {
       return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
     } else if (p instanceof RegexPredicate) {
       if (value.startsWith("^")) {
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
index a693f6d..2f0bd01 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
@@ -40,11 +40,7 @@
       for (Values<V> values : schema.buildFields(v)) {
         String name = values.getField().getName();
         if (values.getField().isRepeatable()) {
-          builder.field(
-              name,
-              Streams.stream(values.getValues())
-                  .filter(e -> shouldAddElement(e))
-                  .collect(toList()));
+          builder.field(name, Streams.stream(values.getValues()).collect(toList()));
         } else {
           Object element = Iterables.getOnlyElement(values.getValues(), "");
           if (shouldAddElement(element)) {
diff --git a/java/com/google/gerrit/reviewdb/client/Account.java b/java/com/google/gerrit/entities/Account.java
similarity index 63%
rename from java/com/google/gerrit/reviewdb/client/Account.java
rename to java/com/google/gerrit/entities/Account.java
index fa3abed..809db1e 100644
--- a/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -12,15 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.entities.RefNames.REFS_STARRED_CHANGES;
+import static com.google.gerrit.entities.RefNames.REFS_USERS;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gwtorm.client.IntKey;
 import java.sql.Timestamp;
 import java.util.Optional;
 
@@ -37,44 +38,24 @@
  *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
  *       Multiple records can exist when the user has more than one public identity, such as a work
  *       and a personal email address.
- *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
- *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
  *   <li>AccountSshKey: user's public SSH keys, for authentication through the internal SSH daemon.
  *       One record per SSH key uploaded by the user, keys are checked in random order until a match
  *       is found.
  *   <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side and unified diff
  * </ul>
  */
-public final class Account {
+@AutoValue
+public abstract class Account {
   public static Id id(int id) {
-    return new Id(id);
+    return new AutoValue_Account_Id(id);
   }
 
   /** Key local to Gerrit to identify a user. */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
+  @AutoValue
+  public abstract static class Id implements Comparable<Id> {
     /** Parse an Account.Id out of a string representation. */
     public static Optional<Id> tryParse(String str) {
-      return Optional.ofNullable(Ints.tryParse(str)).map(Id::new);
+      return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
     public static Id fromRef(String name) {
@@ -99,12 +80,12 @@
      */
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     /**
@@ -119,34 +100,52 @@
      */
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
+    }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    @Override
+    public final int compareTo(Id o) {
+      return Integer.compare(id(), o.id());
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
     }
   }
 
-  private Id accountId;
+  public abstract Id id();
 
   /** Date and time the user registered with the review server. */
-  private Timestamp registeredOn;
+  public abstract Timestamp registeredOn();
 
   /** Full name of the user ("Given-name Surname" style). */
-  private String fullName;
+  @Nullable
+  public abstract String fullName();
 
   /** Email address the user prefers to be contacted through. */
-  private String preferredEmail;
+  @Nullable
+  public abstract String preferredEmail();
 
   /**
    * Is this user inactive? This is used to avoid showing some users (eg. former employees) in
    * auto-suggest.
    */
-  private boolean inactive;
+  public abstract boolean inactive();
 
   /** The user-settable status of this account (e.g. busy, OOO, available) */
-  private String status;
+  @Nullable
+  public abstract String status();
 
   /** ID of the user branch from which the account was read. */
-  private String metaId;
-
-  protected Account() {}
+  @Nullable
+  public abstract String metaId();
 
   /**
    * Create a new account.
@@ -154,38 +153,11 @@
    * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
-  public Account(Account.Id newId, Timestamp registeredOn) {
-    this.accountId = newId;
-    this.registeredOn = registeredOn;
-  }
-
-  /** Get local id of this account, to link with in other entities */
-  public Account.Id getId() {
-    return accountId;
-  }
-
-  /** Get the full name of the user ("Given-name Surname" style). */
-  public String getFullName() {
-    return fullName;
-  }
-
-  /** Set the full name of the user ("Given-name Surname" style). */
-  public void setFullName(String name) {
-    if (name != null && !name.trim().isEmpty()) {
-      fullName = name.trim();
-    } else {
-      fullName = null;
-    }
-  }
-
-  /** Email address the user prefers to be contacted through. */
-  public String getPreferredEmail() {
-    return preferredEmail;
-  }
-
-  /** Set the email address the user prefers to be contacted through. */
-  public void setPreferredEmail(String addr) {
-    preferredEmail = addr;
+  public static Account.Builder builder(Account.Id newId, Timestamp registeredOn) {
+    return new AutoValue_Account.Builder()
+        .setInactive(false)
+        .setId(newId)
+        .setRegisteredOn(registeredOn);
   }
 
   /**
@@ -201,13 +173,13 @@
    *     generic string containing the accountId.
    */
   public String getName() {
-    if (fullName != null) {
-      return fullName;
+    if (fullName() != null) {
+      return fullName();
     }
-    if (preferredEmail != null) {
-      return preferredEmail;
+    if (preferredEmail() != null) {
+      return preferredEmail();
     }
-    return getName(accountId);
+    return getName(id());
   }
 
   public static String getName(Account.Id accountId) {
@@ -227,62 +199,70 @@
    * </ul>
    */
   public String getNameEmail(String anonymousCowardName) {
-    String name = fullName != null ? fullName : anonymousCowardName;
+    String name = fullName() != null ? fullName() : anonymousCowardName;
     StringBuilder b = new StringBuilder();
     b.append(name);
-    if (preferredEmail != null) {
+    if (preferredEmail() != null) {
       b.append(" <");
-      b.append(preferredEmail);
+      b.append(preferredEmail());
       b.append(">");
     } else {
       b.append(" (");
-      b.append(accountId.get());
+      b.append(id().get());
       b.append(")");
     }
     return b.toString();
   }
 
-  /** Get the date and time the user first registered. */
-  public Timestamp getRegisteredOn() {
-    return registeredOn;
-  }
-
-  public String getMetaId() {
-    return metaId;
-  }
-
-  public void setMetaId(String metaId) {
-    this.metaId = metaId;
-  }
-
   public boolean isActive() {
-    return !inactive;
+    return !inactive();
   }
 
-  public void setActive(boolean active) {
-    inactive = !active;
-  }
+  public abstract Builder toBuilder();
 
-  public String getStatus() {
-    return status;
-  }
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Id id();
 
-  public void setStatus(String status) {
-    this.status = status;
+    abstract Builder setId(Id id);
+
+    public abstract Timestamp registeredOn();
+
+    abstract Builder setRegisteredOn(Timestamp registeredOn);
+
+    @Nullable
+    public abstract String fullName();
+
+    public abstract Builder setFullName(String fullName);
+
+    @Nullable
+    public abstract String preferredEmail();
+
+    public abstract Builder setPreferredEmail(String preferredEmail);
+
+    public abstract boolean inactive();
+
+    public abstract Builder setInactive(boolean inactive);
+
+    public Builder setActive(boolean active) {
+      return setInactive(!active);
+    }
+
+    @Nullable
+    public abstract String status();
+
+    public abstract Builder setStatus(String status);
+
+    @Nullable
+    public abstract String metaId();
+
+    public abstract Builder setMetaId(@Nullable String metaId);
+
+    public abstract Account build();
   }
 
   @Override
-  public boolean equals(Object o) {
-    return o instanceof Account && ((Account) o).getId().equals(getId());
-  }
-
-  @Override
-  public int hashCode() {
-    return getId().get();
-  }
-
-  @Override
-  public String toString() {
+  public final String toString() {
     return getName();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
similarity index 82%
rename from java/com/google/gerrit/reviewdb/client/AccountGroup.java
rename to java/com/google/gerrit/entities/AccountGroup.java
index 0db7bbd..c10edc2 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -12,11 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Objects;
@@ -34,63 +33,45 @@
   }
 
   public static NameKey nameKey(String n) {
-    return new NameKey(n);
+    return new AutoValue_AccountGroup_NameKey(n);
   }
 
   /** Group name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class NameKey implements Comparable<NameKey> {
+    abstract String name();
 
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
-    }
-
-    @Override
     public String get() {
-      return name;
+      return name();
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
+    public final int compareTo(NameKey o) {
+      return name().compareTo(o.name());
+    }
+
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
   public static UUID uuid(String n) {
-    return new UUID(n);
+    return new AutoValue_AccountGroup_UUID(n);
   }
 
   /** Globally unique identifier. */
-  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+    abstract String uuid();
 
-    protected String uuid;
-
-    protected UUID() {}
-
-    public UUID(String n) {
-      uuid = n;
-    }
-
-    @Override
     public String get() {
-      return uuid;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      uuid = newValue;
+      return uuid();
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a string representation. */
     public static UUID parse(String str) {
-      final UUID r = new UUID();
-      r.fromString(str);
-      return r;
+      return AccountGroup.uuid(KeyUtil.decode(str));
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
@@ -112,7 +93,17 @@
      */
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
-      return uuid != null ? new AccountGroup.UUID(uuid) : null;
+      return uuid != null ? AccountGroup.uuid(uuid) : null;
+    }
+
+    @Override
+    public final int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -122,36 +113,26 @@
   }
 
   public static Id id(int id) {
-    return new Id(id);
+    return new AutoValue_AccountGroup_Id(id);
   }
 
   /** Synthetic key to link to within the database */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Id {
+    abstract int id();
 
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
     public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
+      return id();
     }
 
     /** Parse an AccountGroup.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      return AccountGroup.id(Integer.parseInt(str));
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
     }
   }
 
diff --git a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
new file mode 100644
index 0000000..17ddf51
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2011 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.entities;
+
+import com.google.auto.value.AutoValue;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
+@AutoValue
+public abstract class AccountGroupByIdAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupByIdAudit.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
+
+    public abstract Builder includeUuid(AccountGroup.UUID includeUuid);
+
+    public abstract Builder addedBy(Account.Id addedBy);
+
+    public abstract Builder addedOn(Timestamp addedOn);
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
+    }
+
+    public abstract AccountGroupByIdAudit build();
+  }
+
+  public abstract AccountGroup.Id groupId();
+
+  public abstract AccountGroup.UUID includeUuid();
+
+  public abstract Account.Id addedBy();
+
+  public abstract Timestamp addedOn();
+
+  public abstract Optional<Account.Id> removedBy();
+
+  public abstract Optional<Timestamp> removedOn();
+
+  public abstract Builder toBuilder();
+
+  public boolean isActive() {
+    return !removedOn().isPresent();
+  }
+}
diff --git a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
new file mode 100644
index 0000000..4d191b8
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
@@ -0,0 +1,74 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/** Membership of an {@link Account} in an {@link AccountGroup}. */
+@AutoValue
+public abstract class AccountGroupMemberAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupMemberAudit.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
+
+    public abstract Builder memberId(Account.Id accountId);
+
+    public abstract Builder addedBy(Account.Id addedBy);
+
+    abstract Account.Id addedBy();
+
+    public abstract Builder addedOn(Timestamp addedOn);
+
+    abstract Timestamp addedOn();
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
+    }
+
+    public Builder removedLegacy() {
+      return removed(addedBy(), addedOn());
+    }
+
+    public abstract AccountGroupMemberAudit build();
+  }
+
+  public abstract AccountGroup.Id groupId();
+
+  public abstract Account.Id memberId();
+
+  public abstract Account.Id addedBy();
+
+  public abstract Timestamp addedOn();
+
+  public abstract Optional<Account.Id> removedBy();
+
+  public abstract Optional<Timestamp> removedOn();
+
+  public abstract Builder toBuilder();
+
+  public boolean isActive() {
+    return !removedOn().isPresent();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/entities/BUILD
similarity index 69%
rename from java/com/google/gerrit/reviewdb/BUILD
rename to java/com/google/gerrit/entities/BUILD
index 3bc6528..8784bd8 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -5,14 +5,17 @@
 )
 
 java_library(
-    name = "server",
+    name = "entities",
     srcs = glob(["**/*.java"]),
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gwtorm",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
similarity index 97%
rename from java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
rename to java/com/google/gerrit/entities/BooleanProjectConfig.java
index a70d254..5201f6d 100644
--- a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 /**
  * Contains all inheritable boolean project configs and maps internal representations to API
diff --git a/java/com/google/gerrit/entities/BranchNameKey.java b/java/com/google/gerrit/entities/BranchNameKey.java
new file mode 100644
index 0000000..cbb2e25
--- /dev/null
+++ b/java/com/google/gerrit/entities/BranchNameKey.java
@@ -0,0 +1,49 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+
+/** Branch name key */
+@AutoValue
+public abstract class BranchNameKey implements Comparable<BranchNameKey> {
+  public static BranchNameKey create(Project.NameKey projectName, String branchName) {
+    return new AutoValue_BranchNameKey(projectName, RefNames.fullName(branchName));
+  }
+
+  public static BranchNameKey create(String projectName, String branchName) {
+    return create(Project.nameKey(projectName), branchName);
+  }
+
+  public abstract Project.NameKey project();
+
+  public abstract String branch();
+
+  public String shortName() {
+    return RefNames.shortName(branch());
+  }
+
+  @Override
+  public final int compareTo(BranchNameKey o) {
+    // TODO(dborowitz): Only compares branch name in order to match old StringKey behavior.
+    // Consider comparing project name first.
+    return branch().compareTo(o.branch());
+  }
+
+  @Override
+  public final String toString() {
+    return project() + "," + KeyUtil.encode(branch());
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/entities/Change.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/client/Change.java
rename to java/com/google/gerrit/entities/Change.java
index 79739dc..739bd38 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -12,19 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
 import java.sql.Timestamp;
 import java.util.Arrays;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 
 /**
- * A change proposed to be merged into a {@link Branch}.
+ * A change proposed to be merged into a branch.
  *
  * <p>The data graph rooted below a Change can be quite complex:
  *
@@ -37,7 +44,7 @@
  *          |
  *          +- {@link PatchSetApproval}: a +/- vote on the change's current state.
  *          |
- *          +- {@link PatchLineComment}: comment about a specific line
+ *          +- {@link Comment}: comment about a specific line
  * </pre>
  *
  * <p>
@@ -53,8 +60,8 @@
  * commit can contain zero patches, if the merge has no conflicts, or has no impact other than to
  * cut off a line of development.
  *
- * <p>Each PatchLineComment is a draft or a published comment about a single line of the associated
- * file. These are the inline comment entities created by users as they perform a review.
+ * <p>Each Comment is a draft or a published comment about a single line of the associated file.
+ * These are the inline comment entities created by users as they perform a review.
  *
  * <p>When additional PatchSets appear under a change, these PatchSets reference <i>replacement</i>
  * commits; alternative commits that could be made to the project instead of the original commit
@@ -69,10 +76,10 @@
  * <h5>ChangeMessage</h5>
  *
  * <p>The ChangeMessage entity is a general free-form comment about the whole change, rather than
- * PatchLineComment's file and line specific context. The ChangeMessage appears at the start of any
- * email generated by Gerrit, and is shown on the change overview page, rather than in a
- * file-specific context. Users often use this entity to describe general remarks about the overall
- * concept proposed by the change.
+ * Comment's file and line specific context. The ChangeMessage appears at the start of any email
+ * generated by Gerrit, and is shown on the change overview page, rather than in a file-specific
+ * context. Users often use this entity to describe general remarks about the overall concept
+ * proposed by the change.
  *
  * <p>
  *
@@ -93,49 +100,27 @@
  * notice of a replacement patch set is sent, or when notice of the change submission occurs.
  */
 public final class Change {
-  public static Id id(int id) {
-    return new Id(id);
+  private static final SecureRandom rng;
+
+  static {
+    try {
+      rng = SecureRandom.getInstance("SHA1PRNG");
+    } catch (NoSuchAlgorithmException e) {
+      throw new RuntimeException("Cannot create RNG for Change-Id generator", e);
+    }
   }
 
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  public static Id id(int id) {
+    return new AutoValue_Change_Id(id);
+  }
 
-    public int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
-    public String toRefPrefix() {
-      return refPrefixBuilder().toString();
-    }
-
-    StringBuilder refPrefixBuilder() {
-      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
-      int m = id % 100;
-      if (m < 10) {
-        r.append('0');
-      }
-      return r.append(m).append('/').append(id).append('/');
-    }
-
+  @AutoValue
+  public abstract static class Id {
     /** Parse a Change.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      Integer id = Ints.tryParse(str);
+      checkArgument(id != null, "invalid change ID: %s", str);
+      return Change.id(id);
     }
 
     public static Id fromRef(String ref) {
@@ -150,7 +135,7 @@
       if (ref.substring(ce).equals(RefNames.META_SUFFIX)
           || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX)
           || PatchSet.Id.fromRef(ref, ce) >= 0) {
-        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+        return Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -173,7 +158,7 @@
       }
       int ce = nextNonDigit(ref, cs);
       if (ce < ref.length() && ref.charAt(ce) == '/' && isNumeric(ref, ce + 1)) {
-        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+        return Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -195,14 +180,14 @@
       int endChangeId = nextNonDigit(ref, startChangeId);
       String id = ref.substring(startChangeId, endChangeId);
       if (id != null && !id.isEmpty()) {
-        return new Change.Id(Integer.parseInt(id));
+        return Change.id(Integer.parseInt(id));
       }
       return null;
     }
 
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
-      return id != null ? new Change.Id(id) : null;
+      return id != null ? Change.id(id) : null;
     }
 
     static int startIndex(String ref) {
@@ -253,35 +238,66 @@
       }
       return i;
     }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefPrefix() {
+      return refPrefixBuilder().toString();
+    }
+
+    StringBuilder refPrefixBuilder() {
+      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
+      int m = get() % 100;
+      if (m < 10) {
+        r.append('0');
+      }
+      return r.append(m).append('/').append(get()).append('/');
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
+    }
+  }
+
+  public static ObjectId generateChangeId() {
+    byte[] rand = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+    rng.nextBytes(rand);
+    String randomString = new String(rand, UTF_8);
+
+    try (ObjectInserter f = new ObjectInserter.Formatter()) {
+      return f.idFor(Constants.OBJ_COMMIT, Constants.encode(randomString));
+    }
+  }
+
+  public static Key generateKey() {
+    return key("I" + generateChangeId().name());
   }
 
   public static Key key(String key) {
-    return new Key(key);
+    return new AutoValue_Change_Key(key);
   }
 
   /**
    * Globally unique identification of this change. This generally takes the form of a string
    * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
    */
-  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    protected String id;
-
-    protected Key() {}
-
-    public Key(String id) {
-      this.id = id;
+  @AutoValue
+  public abstract static class Key {
+    // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
+    // Ideally the standard key() factory method would enforce the format and throw IAE.
+    public static Key parse(String str) {
+      return Change.key(KeyUtil.decode(str));
     }
 
-    @Override
+    abstract String key();
+
     public String get() {
-      return id;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      id = newValue;
+      return key();
     }
 
     /** Construct a key that is after all keys prefixed by this key. */
@@ -289,7 +305,7 @@
       final StringBuilder revEnd = new StringBuilder(get().length() + 1);
       revEnd.append(get());
       revEnd.append('\u9fa5');
-      return new Key(revEnd.toString());
+      return Change.key(revEnd.toString());
     }
 
     /** Obtain a shorter version of this key string, using a leading prefix. */
@@ -298,11 +314,9 @@
       return s.substring(0, Math.min(s.length(), 9));
     }
 
-    /** Parse a Change.Key out of a string representation. */
-    public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+    @Override
+    public final String toString() {
+      return get();
     }
   }
 
@@ -412,13 +426,6 @@
         }
       }
 
-      // TODO(davido): Remove in 3.0, after all sites upgraded to version,
-      // where DRAFT status was removed. This code path is still needed,
-      // when changes are deserialized from the secondary index, during
-      // the online migration to the new schema version wasn't completed.
-      if (c == 'd') {
-        return Status.NEW;
-      }
       return null;
     }
 
@@ -456,7 +463,7 @@
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  protected Branch.NameKey dest;
+  protected BranchNameKey dest;
 
   // DELETED: id = 9 (open)
 
@@ -512,7 +519,7 @@
       Change.Key newKey,
       Change.Id newId,
       Account.Id ownedBy,
-      Branch.NameKey forBranch,
+      BranchNameKey forBranch,
       Timestamp ts) {
     changeKey = newKey;
     changeId = newId;
@@ -599,16 +606,16 @@
     this.owner = owner;
   }
 
-  public Branch.NameKey getDest() {
+  public BranchNameKey getDest() {
     return dest;
   }
 
-  public void setDest(Branch.NameKey dest) {
+  public void setDest(BranchNameKey dest) {
     this.dest = dest;
   }
 
   public Project.NameKey getProject() {
-    return dest.getParentKey();
+    return dest.project();
   }
 
   public String getSubject() {
@@ -626,7 +633,7 @@
   /** Get the id of the most current {@link PatchSet} in this change. */
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
-      return new PatchSet.Id(changeId, currentPatchSetId);
+      return PatchSet.id(changeId, currentPatchSetId);
     }
     return null;
   }
@@ -649,7 +656,7 @@
   }
 
   public void setCurrentPatchSet(PatchSet.Id psId, String subject, String originalSubject) {
-    if (!psId.getParentKey().equals(changeId)) {
+    if (!psId.changeId().equals(changeId)) {
       throw new IllegalArgumentException("patch set ID " + psId + " is not for change " + changeId);
     }
     currentPatchSetId = psId.get();
diff --git a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
similarity index 82%
rename from java/com/google/gerrit/reviewdb/client/ChangeMessage.java
rename to java/com/google/gerrit/entities/ChangeMessage.java
index de318bd..f34cc7d 100644
--- a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -12,57 +12,24 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.util.Objects;
 
 /** A message attached to a {@link Change}. */
 public final class ChangeMessage {
   public static Key key(Change.Id changeId, String uuid) {
-    return new Key(changeId, uuid);
+    return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
 
-  public static class Key extends StringKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Key {
+    public abstract Change.Id changeId();
 
-    protected Change.Id changeId;
-
-    protected String uuid;
-
-    protected Key() {
-      changeId = new Change.Id();
-    }
-
-    public Key(Change.Id change, String uuid) {
-      this.changeId = change;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    public Change.Id changeId() {
-      return getParentKey();
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    public String uuid() {
-      return get();
-    }
-
-    @Override
-    public void set(String newValue) {
-      uuid = newValue;
-    }
+    public abstract String uuid();
   }
 
   protected Key key;
diff --git a/java/com/google/gerrit/reviewdb/client/CodedEnum.java b/java/com/google/gerrit/entities/CodedEnum.java
similarity index 94%
rename from java/com/google/gerrit/reviewdb/client/CodedEnum.java
rename to java/com/google/gerrit/entities/CodedEnum.java
index 11e7efa..90629a0 100644
--- a/java/com/google/gerrit/reviewdb/client/CodedEnum.java
+++ b/java/com/google/gerrit/entities/CodedEnum.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 /** Extension of Enum which provides distinct character code values. */
 public interface CodedEnum {
diff --git a/java/com/google/gerrit/reviewdb/client/Comment.java b/java/com/google/gerrit/entities/Comment.java
similarity index 62%
rename from java/com/google/gerrit/reviewdb/client/Comment.java
rename to java/com/google/gerrit/entities/Comment.java
index e03d0fa..55d739a 100644
--- a/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -12,11 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 import java.util.Comparator;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * This class represents inline comments in NoteDb. This means it determines the JSON format for
@@ -24,30 +29,33 @@
  *
  * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
  * require a corresponding data migration (adding new optional fields is generally okay).
- *
- * <p>{@link PatchLineComment} historically represented comments in ReviewDb. There are a few
- * notable differences:
- *
- * <ul>
- *   <li>PatchLineComment knows the comment status (published or draft). For comments in NoteDb the
- *       status is determined by the branch in which they are stored (published comments are stored
- *       in the change meta ref; draft comments are store in refs/draft-comments branches in
- *       All-Users). Hence Comment doesn't need to contain the status, but the status is implicitly
- *       known by where the comments are read from.
- *   <li>PatchLineComment knows the change ID. For comments in NoteDb, the change ID is determined
- *       by the branch in which they are stored (the ref name contains the change ID). Hence Comment
- *       doesn't need to contain the change ID, but the change ID is implicitly known by where the
- *       comments are read from.
- * </ul>
- *
- * <p>For all utility classes and middle layer functionality using Comment over PatchLineComment is
- * preferred, as ReviewDb is gone so PatchLineComment is slated for deletion as well. This means
- * Comment should be used everywhere and only for storing inline comment in ReviewDb a conversion to
- * PatchLineComment is done. Converting Comments to PatchLineComments and vice verse is done by
- * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable) and
- * CommentsUtil#toComments(String, Iterable).
  */
 public class Comment {
+  public enum Status {
+    DRAFT('d'),
+
+    PUBLISHED('P');
+
+    private final char code;
+
+    Status(char c) {
+      code = c;
+    }
+
+    public char getCode() {
+      return code;
+    }
+
+    public static Status forCode(char c) {
+      for (Status s : Status.values()) {
+        if (s.code == c) {
+          return s;
+        }
+      }
+      return null;
+    }
+  }
+
   public static class Key {
     public String uuid;
     public String filename;
@@ -65,17 +73,10 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Key{")
-          .append("uuid=")
-          .append(uuid)
-          .append(',')
-          .append("filename=")
-          .append(filename)
-          .append(',')
-          .append("patchSetId=")
-          .append(patchSetId)
-          .append('}')
+      return MoreObjects.toStringHelper(this)
+          .add("uuid", uuid)
+          .add("filename", filename)
+          .add("patchSetId", patchSetId)
           .toString();
     }
 
@@ -104,7 +105,7 @@
     }
 
     public Account.Id getId() {
-      return new Account.Id(id);
+      return Account.id(id);
     }
 
     @Override
@@ -122,15 +123,30 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Identity{")
-          .append("id=")
-          .append(id)
-          .append('}')
-          .toString();
+      return MoreObjects.toStringHelper(this).add("id", id).toString();
     }
   }
 
+  /**
+   * The Range class defines continuous range of character.
+   *
+   * <p>The pair (startLine, startChar) defines the first character in the range. The pair (endLine,
+   * endChar) defines the first character AFTER the range (i.e. it doesn't belong the range).
+   * (endLine, endChar) must be a valid character inside text, except EOF case.
+   *
+   * <p>Special cases:
+   *
+   * <ul>
+   *   <li>Zero length range: (startLine, startChar) = (endLine, endChar). Range defines insert
+   *       position right before the (startLine, startChar) character (for {@link FixReplacement})
+   *   <li>EOF case - range includes the last character in the file:
+   *       <ul>
+   *         <li>if a file ends with EOL mark, then (endLine, endChar) = (num_of_lines + 1, 0)
+   *         <li>if a file doesn't end with EOL mark, then (endLine, endChar) = (num_of_lines,
+   *             num_of_chars_in_last_line)
+   *       </ul>
+   * </ul>
+   */
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
@@ -138,10 +154,10 @@
             .thenComparingInt(range -> range.endLine)
             .thenComparingInt(range -> range.endChar);
 
-    public int startLine; // 1-based, inclusive
-    public int startChar; // 0-based, inclusive
-    public int endLine; // 1-based, exclusive
-    public int endChar; // 0-based, exclusive
+    public int startLine; // 1-based
+    public int startChar; // 0-based
+    public int endLine; // 1-based
+    public int endChar; // 0-based
 
     public Range(Range r) {
       this(r.startLine, r.startChar, r.endLine, r.endChar);
@@ -177,20 +193,11 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Range{")
-          .append("startLine=")
-          .append(startLine)
-          .append(',')
-          .append("startChar=")
-          .append(startChar)
-          .append(',')
-          .append("endLine=")
-          .append(endLine)
-          .append(',')
-          .append("endChar=")
-          .append(endChar)
-          .append('}')
+      return MoreObjects.toStringHelper(this)
+          .add("startLine", startLine)
+          .add("startChar", startChar)
+          .add("endLine", endLine)
+          .add("endChar", endChar)
           .toString();
     }
 
@@ -201,7 +208,9 @@
   }
 
   public Key key;
+  /** The line number (1-based) to which the comment refers, or 0 for a file comment. */
   public int lineNbr;
+
   public Identity author;
   protected Identity realAuthor;
   public Timestamp writtenOn;
@@ -211,17 +220,15 @@
   public Range range;
   public String tag;
 
-  // Hex commit SHA1 of the commit of the patchset to which this comment applies.
-  public String revId;
+  // Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
+  // this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
+  // serialized into JSON in NoteDb, so it can't easily be changed. Callers do not access this field
+  // directly, and instead use the public getter/setter that wraps an ObjectId.
+  private String revId;
+
   public String serverId;
   public boolean unresolved;
 
-  /**
-   * Whether the comment was parsed from a JSON representation (false) or the legacy custom notes
-   * format (true).
-   */
-  public transient boolean legacyFormat;
-
   public Comment(Comment c) {
     this(
         new Key(c.key),
@@ -269,8 +276,13 @@
     this.range = range != null ? range.asCommentRange() : null;
   }
 
-  public void setRevId(RevId revId) {
-    this.revId = revId != null ? revId.get() : null;
+  @Nullable
+  public ObjectId getCommitId() {
+    return revId != null ? ObjectId.fromString(revId) : null;
+  }
+
+  public void setCommitId(@Nullable AnyObjectId commitId) {
+    this.revId = commitId != null ? commitId.name() : null;
   }
 
   public void setRealAuthor(Account.Id id) {
@@ -322,44 +334,22 @@
 
   @Override
   public String toString() {
-    return new StringBuilder()
-        .append("Comment{")
-        .append("key=")
-        .append(key)
-        .append(',')
-        .append("lineNbr=")
-        .append(lineNbr)
-        .append(',')
-        .append("author=")
-        .append(author.getId().get())
-        .append(',')
-        .append("realAuthor=")
-        .append(realAuthor != null ? realAuthor.getId().get() : "")
-        .append(',')
-        .append("writtenOn=")
-        .append(writtenOn.toString())
-        .append(',')
-        .append("side=")
-        .append(side)
-        .append(',')
-        .append("message=")
-        .append(Objects.toString(message, ""))
-        .append(',')
-        .append("parentUuid=")
-        .append(Objects.toString(parentUuid, ""))
-        .append(',')
-        .append("range=")
-        .append(Objects.toString(range, ""))
-        .append(',')
-        .append("revId=")
-        .append(revId != null ? revId : "")
-        .append(',')
-        .append("tag=")
-        .append(Objects.toString(tag, ""))
-        .append(',')
-        .append("unresolved=")
-        .append(unresolved)
-        .append('}')
-        .toString();
+    return toStringHelper().toString();
+  }
+
+  protected ToStringHelper toStringHelper() {
+    return MoreObjects.toStringHelper(this)
+        .add("key", key)
+        .add("lineNbr", lineNbr)
+        .add("author", author.getId())
+        .add("realAuthor", realAuthor != null ? realAuthor.getId() : "")
+        .add("writtenOn", writtenOn)
+        .add("side", side)
+        .add("message", Objects.toString(message, ""))
+        .add("parentUuid", Objects.toString(parentUuid, ""))
+        .add("range", Objects.toString(range, ""))
+        .add("revId", Objects.toString(revId, ""))
+        .add("tag", Objects.toString(tag, ""))
+        .add("unresolved", unresolved);
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/CommentRange.java b/java/com/google/gerrit/entities/CommentRange.java
similarity index 97%
rename from java/com/google/gerrit/reviewdb/client/CommentRange.java
rename to java/com/google/gerrit/entities/CommentRange.java
index e6c5078..f58780f 100644
--- a/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/java/com/google/gerrit/entities/CommentRange.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 public class CommentRange {
 
diff --git a/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
similarity index 95%
rename from java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
rename to java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 2ca89c8..37c10f1 100644
--- a/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 /** Download scheme string constants supported by the download-commands core plugin. */
 public class CoreDownloadSchemes {
diff --git a/java/com/google/gerrit/reviewdb/client/FixReplacement.java b/java/com/google/gerrit/entities/FixReplacement.java
similarity index 96%
rename from java/com/google/gerrit/reviewdb/client/FixReplacement.java
rename to java/com/google/gerrit/entities/FixReplacement.java
index 66630e4..046300e 100644
--- a/java/com/google/gerrit/reviewdb/client/FixReplacement.java
+++ b/java/com/google/gerrit/entities/FixReplacement.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 public class FixReplacement {
   public String path;
diff --git a/java/com/google/gerrit/reviewdb/client/FixSuggestion.java b/java/com/google/gerrit/entities/FixSuggestion.java
similarity index 96%
rename from java/com/google/gerrit/reviewdb/client/FixSuggestion.java
rename to java/com/google/gerrit/entities/FixSuggestion.java
index d766a3a..ac4e720 100644
--- a/java/com/google/gerrit/reviewdb/client/FixSuggestion.java
+++ b/java/com/google/gerrit/entities/FixSuggestion.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import java.util.List;
 
diff --git a/java/com/google/gwtorm/client/StandardKeyEncoder.java b/java/com/google/gerrit/entities/KeyUtil.java
similarity index 91%
rename from java/com/google/gwtorm/client/StandardKeyEncoder.java
rename to java/com/google/gerrit/entities/KeyUtil.java
index 1ceb0dc..40fb757 100644
--- a/java/com/google/gwtorm/client/StandardKeyEncoder.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -1,4 +1,4 @@
-// Copyright 2008 Google Inc.
+// 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.
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gwtorm.client;
+package com.google.gerrit.entities;
 
-import com.google.gwtorm.client.KeyUtil.Encoder;
 import java.io.UnsupportedEncodingException;
 import java.util.Arrays;
 
-public class StandardKeyEncoder extends Encoder {
+public class KeyUtil {
   private static final char[] hexc = {
     '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
   };
@@ -49,8 +48,7 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  @Override
-  public String encode(final String e) {
+  public static String encode(final String e) {
     final byte[] b;
     try {
       b = e.getBytes("UTF-8");
@@ -73,8 +71,7 @@
     return r.toString();
   }
 
-  @Override
-  public String decode(final String e) {
+  public static String decode(final String e) {
     if (e.indexOf('%') < 0) {
       return e.replace('+', ' ');
     }
diff --git a/java/com/google/gerrit/reviewdb/client/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
similarity index 62%
rename from java/com/google/gerrit/reviewdb/client/LabelId.java
rename to java/com/google/gerrit/entities/LabelId.java
index abf131b..1cc45c8 100644
--- a/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -12,38 +12,25 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
-import com.google.gwtorm.client.StringKey;
+import com.google.auto.value.AutoValue;
 
-public class LabelId extends StringKey<com.google.gwtorm.client.Key<?>> {
-  private static final long serialVersionUID = 1L;
-
+@AutoValue
+public abstract class LabelId {
   static final String LEGACY_SUBMIT_NAME = "SUBM";
 
   public static LabelId create(String n) {
-    return new LabelId(n);
+    return new AutoValue_LabelId(n);
   }
 
   public static LabelId legacySubmit() {
-    return new LabelId(LEGACY_SUBMIT_NAME);
+    return create(LEGACY_SUBMIT_NAME);
   }
 
-  public String id;
+  abstract String id();
 
-  public LabelId() {}
-
-  public LabelId(String n) {
-    id = n;
-  }
-
-  @Override
   public String get() {
-    return id;
-  }
-
-  @Override
-  protected void set(String newValue) {
-    id = newValue;
+    return id();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/Patch.java b/java/com/google/gerrit/entities/Patch.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/client/Patch.java
rename to java/com/google/gerrit/entities/Patch.java
index d192df0..cc38bda 100644
--- a/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -12,9 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
-import com.google.gwtorm.client.StringKey;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.primitives.Ints;
+import java.util.List;
 
 /** A single modified file in a {@link PatchSet}. */
 public final class Patch {
@@ -36,58 +41,29 @@
   }
 
   public static Key key(PatchSet.Id patchSetId, String fileName) {
-    return new Key(patchSetId, fileName);
+    return new AutoValue_Patch_Key(patchSetId, fileName);
   }
 
-  public static class Key extends StringKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected PatchSet.Id patchSetId;
-
-    protected String fileName;
-
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-    }
-
-    public Key(PatchSet.Id ps, String name) {
-      this.patchSetId = ps;
-      this.fileName = name;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    public PatchSet.Id patchSetId() {
-      return getParentKey();
-    }
-
-    @Override
-    public String get() {
-      return fileName;
-    }
-
-    public String fileName() {
-      return get();
-    }
-
-    @Override
-    protected void set(String newValue) {
-      fileName = newValue;
-    }
-
-    /** Parse a Patch.Id out of a string representation. */
+  @AutoValue
+  public abstract static class Key {
+    /** Parse a Patch.Key out of a string representation. */
     public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+      List<String> parts = Splitter.on(',').limit(3).splitToList(str);
+      checkKeyFormat(parts.size() == 3, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkKeyFormat(changeId != null, str);
+      Integer patchSetNum = Ints.tryParse(parts.get(1));
+      checkKeyFormat(patchSetNum != null, str);
+      return key(PatchSet.id(Change.id(changeId), patchSetNum), parts.get(2));
     }
 
-    public String getFileName() {
-      return get();
+    private static void checkKeyFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch key: %s", input);
     }
+
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract String fileName();
   }
 
   /** Type of modification made to the file path. */
@@ -271,7 +247,7 @@
   }
 
   public String getFileName() {
-    return key.fileName;
+    return key.fileName();
   }
 
   public String getSourceFileName() {
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
new file mode 100644
index 0000000..8b93dbc
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2008 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.entities;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.common.primitives.Ints;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** A single revision of a {@link Change}. */
+@AutoValue
+public abstract class PatchSet {
+  /** Is the reference name a change reference? */
+  public static boolean isChangeRef(String name) {
+    return Id.fromRef(name) != null;
+  }
+
+  /**
+   * Is the reference name a change reference?
+   *
+   * @deprecated use isChangeRef instead.
+   */
+  @Deprecated
+  public static boolean isRef(String name) {
+    return isChangeRef(name);
+  }
+
+  public static String joinGroups(List<String> groups) {
+    requireNonNull(groups);
+    for (String group : groups) {
+      checkArgument(!group.contains(","), "group may not contain ',': %s", group);
+    }
+    return String.join(",", groups);
+  }
+
+  public static ImmutableList<String> splitGroups(String joinedGroups) {
+    return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
+  }
+
+  public static Id id(Change.Id changeId, int id) {
+    return new AutoValue_PatchSet_Id(changeId, id);
+  }
+
+  @AutoValue
+  public abstract static class Id {
+    /** Parse a PatchSet.Id out of a string representation. */
+    public static Id parse(String str) {
+      List<String> parts = Splitter.on(',').splitToList(str);
+      checkIdFormat(parts.size() == 2, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkIdFormat(changeId != null, str);
+      Integer id = Ints.tryParse(parts.get(1));
+      checkIdFormat(id != null, str);
+      return PatchSet.id(Change.id(changeId), id);
+    }
+
+    private static void checkIdFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch set ID: %s", input);
+    }
+
+    /** Parse a PatchSet.Id from a {@link #refName()} result. */
+    public static Id fromRef(String ref) {
+      int cs = Change.Id.startIndex(ref);
+      if (cs < 0) {
+        return null;
+      }
+      int ce = Change.Id.nextNonDigit(ref, cs);
+      int patchSetId = fromRef(ref, ce);
+      if (patchSetId < 0) {
+        return null;
+      }
+      int changeId = Integer.parseInt(ref.substring(cs, ce));
+      return PatchSet.id(Change.id(changeId), patchSetId);
+    }
+
+    static int fromRef(String ref, int changeIdEnd) {
+      // Patch set ID.
+      int ps = changeIdEnd + 1;
+      if (ps >= ref.length() || ref.charAt(ps) == '0') {
+        return -1;
+      }
+      for (int i = ps; i < ref.length(); i++) {
+        if (ref.charAt(i) < '0' || ref.charAt(i) > '9') {
+          return -1;
+        }
+      }
+      return Integer.parseInt(ref.substring(ps));
+    }
+
+    public static String toId(int number) {
+      return number == 0 ? "edit" : String.valueOf(number);
+    }
+
+    public String getId() {
+      return toId(id());
+    }
+
+    public abstract Change.Id changeId();
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefName() {
+      return changeId().refPrefixBuilder().append(id()).toString();
+    }
+
+    @Override
+    public final String toString() {
+      return changeId().toString() + ',' + id();
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_PatchSet.Builder().groups(ImmutableList.of());
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(Id id);
+
+    public abstract Id id();
+
+    public abstract Builder commitId(ObjectId commitId);
+
+    public abstract Optional<ObjectId> commitId();
+
+    public abstract Builder uploader(Account.Id uploader);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder groups(Iterable<String> groups);
+
+    public abstract ImmutableList<String> groups();
+
+    public abstract Builder pushCertificate(Optional<String> pushCertificate);
+
+    public abstract Builder pushCertificate(String pushCertificate);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder description(String description);
+
+    public abstract Optional<String> description();
+
+    public abstract PatchSet build();
+  }
+
+  /** ID of the patch set. */
+  public abstract Id id();
+
+  /**
+   * Commit ID of the patch set, also known as the revision.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code commitId}, then this method will
+   * return {@link ObjectId#zeroId()}.
+   */
+  public abstract ObjectId commitId();
+
+  /**
+   * Account that uploaded the patch set.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
+   * return an account ID of 0.
+   */
+  public abstract Account.Id uploader();
+
+  /**
+   * When this patch set was first introduced onto the change.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code createdOn}, then this method will
+   * return a timestamp of 0.
+   */
+  public abstract Timestamp createdOn();
+
+  /**
+   * Opaque group identifier, usually assigned during creation.
+   *
+   * <p>This field is actually a comma-separated list of values, as in rare cases involving merge
+   * commits a patch set may belong to multiple groups.
+   *
+   * <p>Changes on the same branch having patch sets with intersecting groups are considered
+   * related, as in the "Related Changes" tab.
+   */
+  public abstract ImmutableList<String> groups();
+
+  /** Certificate sent with a push that created this patch set. */
+  public abstract Optional<String> pushCertificate();
+
+  /**
+   * Optional user-supplied description for this patch set.
+   *
+   * <p>When this field is an empty {@code Optional}, the description was never set on the patch
+   * set. When this field is present but an empty string, the description was set and later cleared.
+   */
+  public abstract Optional<String> description();
+
+  /** Patch set number. */
+  public int number() {
+    return id().get();
+  }
+
+  /** Name of the corresponding patch set ref. */
+  public String refName() {
+    return id().toRefName();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
new file mode 100644
index 0000000..a4bb251
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2008 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Shorts;
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.Optional;
+
+/** An approval (or negative approval) on a patch set. */
+@AutoValue
+public abstract class PatchSetApproval {
+  public static Key key(PatchSet.Id patchSetId, Account.Id accountId, LabelId labelId) {
+    return new AutoValue_PatchSetApproval_Key(patchSetId, accountId, labelId);
+  }
+
+  @AutoValue
+  public abstract static class Key {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract Account.Id accountId();
+
+    public abstract LabelId labelId();
+
+    public boolean isLegacySubmit() {
+      return LabelId.LEGACY_SUBMIT_NAME.equals(labelId().get());
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_PatchSetApproval.Builder().postSubmit(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder key(Key key);
+
+    public abstract Key key();
+
+    public abstract Builder value(short value);
+
+    public Builder value(int value) {
+      return value(Shorts.checkedCast(value));
+    }
+
+    public abstract Builder granted(Timestamp granted);
+
+    public Builder granted(Date granted) {
+      return granted(new Timestamp(granted.getTime()));
+    }
+
+    public abstract Builder tag(String tag);
+
+    public abstract Builder tag(Optional<String> tag);
+
+    public abstract Builder realAccountId(Account.Id realAccountId);
+
+    abstract Optional<Account.Id> realAccountId();
+
+    public abstract Builder postSubmit(boolean isPostSubmit);
+
+    abstract PatchSetApproval autoBuild();
+
+    public PatchSetApproval build() {
+      if (!realAccountId().isPresent()) {
+        realAccountId(key().accountId());
+      }
+      return autoBuild();
+    }
+  }
+
+  public abstract Key key();
+
+  /**
+   * Value assigned by the user.
+   *
+   * <p>The precise meaning of "value" is up to each category.
+   *
+   * <p>In general:
+   *
+   * <ul>
+   *   <li><b>&lt; 0:</b> The approval is rejected/revoked.
+   *   <li><b>= 0:</b> No indication either way is provided.
+   *   <li><b>&gt; 0:</b> The approval is approved/positive.
+   * </ul>
+   *
+   * and in the negative and positive direction a magnitude can be assumed.The further from 0 the
+   * more assertive the approval.
+   */
+  public abstract short value();
+
+  public abstract Timestamp granted();
+
+  public abstract Optional<String> tag();
+
+  /** Real user that made this approval on behalf of the user recorded in {@link Key#accountId}. */
+  public abstract Account.Id realAccountId();
+
+  public abstract boolean postSubmit();
+
+  public abstract Builder toBuilder();
+
+  public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
+    return toBuilder().key(key(psId, key().accountId(), key().labelId())).build();
+  }
+
+  public PatchSet.Id patchSetId() {
+    return key().patchSetId();
+  }
+
+  public Account.Id accountId() {
+    return key().accountId();
+  }
+
+  public LabelId labelId() {
+    return key().labelId();
+  }
+
+  public String label() {
+    return labelId().get();
+  }
+
+  public boolean isLegacySubmit() {
+    return key().isLegacySubmit();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/java/com/google/gerrit/entities/PatchSetInfo.java
similarity index 81%
rename from java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
rename to java/com/google/gerrit/entities/PatchSetInfo.java
index f949013..e3c6613 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
+++ b/java/com/google/gerrit/entities/PatchSetInfo.java
@@ -12,19 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
+
+import static java.util.Objects.requireNonNull;
 
 import java.util.List;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Additional data about a {@link PatchSet} not normally loaded. */
 public final class PatchSetInfo {
   public static class ParentInfo {
-    public RevId id;
+    public ObjectId commitId;
     public String shortMessage;
 
-    public ParentInfo(RevId id, String shortMessage) {
-      this.id = id;
-      this.shortMessage = shortMessage;
+    public ParentInfo(AnyObjectId commitId, String shortMessage) {
+      this.commitId = commitId.copy();
+      this.shortMessage = requireNonNull(shortMessage);
     }
 
     protected ParentInfo() {}
@@ -47,8 +51,8 @@
   /** List of parents of the patch set. */
   protected List<ParentInfo> parents;
 
-  /** SHA-1 of commit */
-  protected String revId;
+  /** ID of commit. */
+  protected ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
   protected String description;
@@ -107,12 +111,12 @@
     return parents;
   }
 
-  public void setRevId(String s) {
-    revId = s;
+  public void setCommitId(AnyObjectId commitId) {
+    this.commitId = commitId.copy();
   }
 
-  public String getRevId() {
-    return revId;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public void setDescription(String description) {
diff --git a/java/com/google/gerrit/reviewdb/client/Project.java b/java/com/google/gerrit/entities/Project.java
similarity index 82%
rename from java/com/google/gerrit/reviewdb/client/Project.java
rename to java/com/google/gerrit/entities/Project.java
index e025c0f..867b14d 100644
--- a/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
+
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwtorm.client.StringKey;
+import java.io.Serializable;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
@@ -35,50 +37,60 @@
     return new NameKey(name);
   }
 
-  /** Project name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
+  /**
+   * Project name key.
+   *
+   * <p>This class has subclasses such as {@code AllProjectsName}, which make Guice injection more
+   * convenient. Subclasses must compare equal if they have the same name, regardless of the
+   * specific class. This implies that subclasses may not add additional fields.
+   *
+   * <p>Because of this unusual subclassing behavior, this class is not an {@code @AutoValue},
+   * unlike other key types in this package. However, this is strictly an implementation detail; its
+   * interface and semantics are otherwise analogous to the {@code @AutoValue} types.
+   */
+  public static class NameKey implements Serializable, Comparable<NameKey> {
     private static final long serialVersionUID = 1L;
 
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
+    /** Parse a Project.NameKey out of a string representation. */
+    public static NameKey parse(String str) {
+      return nameKey(KeyUtil.decode(str));
     }
 
-    @Override
+    public static String asStringOrNull(NameKey key) {
+      return key == null ? null : key.get();
+    }
+
+    private final String name;
+
+    protected NameKey(String name) {
+      this.name = requireNonNull(name);
+    }
+
     public String get() {
       return name;
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
-    }
-
-    @Override
-    public int hashCode() {
+    public final int hashCode() {
       return get().hashCode();
     }
 
     @Override
-    public boolean equals(Object b) {
+    public final boolean equals(Object b) {
       if (b instanceof NameKey) {
         return get().equals(((NameKey) b).get());
       }
       return false;
     }
 
-    /** Parse a Project.NameKey out of a string representation. */
-    public static NameKey parse(String str) {
-      final NameKey r = new NameKey();
-      r.fromString(str);
-      return r;
+    @Override
+    public final int compareTo(NameKey o) {
+      return get().compareTo(o.get());
     }
 
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -220,7 +232,7 @@
   }
 
   public void setParentName(String n) {
-    parent = n != null ? new NameKey(n) : null;
+    parent = n != null ? nameKey(n) : null;
   }
 
   public void setParentName(NameKey n) {
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
similarity index 96%
rename from java/com/google/gerrit/reviewdb/client/RefNames.java
rename to java/com/google/gerrit/entities/RefNames.java
index 1f11921..400861c 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import com.google.gerrit.common.UsedAt;
 
@@ -123,6 +123,16 @@
     return shard(id.get(), r).append(META_SUFFIX).toString();
   }
 
+  public static String patchSetRef(PatchSet.Id id) {
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    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/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
new file mode 100644
index 0000000..a7951ad
--- /dev/null
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -0,0 +1,53 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class RobotComment extends Comment {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+  public Map<String, String> properties;
+  public List<FixSuggestion> fixSuggestions;
+
+  public RobotComment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      String robotId,
+      String robotRunId) {
+    super(key, author, writtenOn, side, message, serverId, false);
+    this.robotId = robotId;
+    this.robotRunId = robotRunId;
+  }
+
+  @Override
+  public String toString() {
+    return toStringHelper()
+        .add("robotId", robotId)
+        .add("robotRunId", robotRunId)
+        .add("url", url)
+        .add("properties", Objects.toString(properties, ""))
+        .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmoduleSubscription.java b/java/com/google/gerrit/entities/SubmoduleSubscription.java
new file mode 100644
index 0000000..5ea1b1e
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmoduleSubscription.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2011 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.entities;
+
+import java.util.Objects;
+
+/**
+ * Defining a project/branch subscription to a project/branch project.
+ *
+ * <p>This means a class instance represents a repo/branch subscription to a project/branch (the
+ * subscriber).
+ *
+ * <p>A subscriber operates a submodule in defined path.
+ */
+public final class SubmoduleSubscription {
+  protected BranchNameKey superProject;
+
+  protected String submodulePath;
+
+  protected BranchNameKey submodule;
+
+  public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
+    this.superProject = superProject;
+    this.submodule = submodule;
+    this.submodulePath = path;
+  }
+
+  /**
+   * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
+   * submodules.
+   */
+  public BranchNameKey getSuperProject() {
+    return superProject;
+  }
+
+  public String getPath() {
+    return submodulePath;
+  }
+
+  public BranchNameKey getSubmodule() {
+    return submodule;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof SubmoduleSubscription) {
+      SubmoduleSubscription s = (SubmoduleSubscription) o;
+      return superProject.equals(s.superProject)
+          && submodulePath.equals(s.submodulePath)
+          && submodule.equals(s.submodule);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(superProject, submodulePath, submodule);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(getSuperProject()).append(':').append(getPath());
+    sb.append(" follows ");
+    sb.append(getSubmodule());
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/UserIdentity.java b/java/com/google/gerrit/entities/UserIdentity.java
similarity index 97%
rename from java/com/google/gerrit/reviewdb/client/UserIdentity.java
rename to java/com/google/gerrit/entities/UserIdentity.java
index 0b7aee3..e07d21a 100644
--- a/java/com/google/gerrit/reviewdb/client/UserIdentity.java
+++ b/java/com/google/gerrit/entities/UserIdentity.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
rename to java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
index 8dd1794..1e846fb 100644
--- a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
   INSTANCE;
 
@@ -28,7 +30,7 @@
 
   @Override
   public Account.Id fromProto(Entities.Account_Id proto) {
-    return new Account.Id(proto.getId());
+    return Account.id(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/BranchNameKeyProtoConverter.java
similarity index 63%
rename from java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
rename to java/com/google/gerrit/entities/converter/BranchNameKeyProtoConverter.java
index 4558f9b..4d0e306 100644
--- a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/BranchNameKeyProtoConverter.java
@@ -12,32 +12,34 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum BranchNameKeyProtoConverter
-    implements ProtoConverter<Entities.Branch_NameKey, Branch.NameKey> {
+    implements ProtoConverter<Entities.Branch_NameKey, BranchNameKey> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Project_NameKey, Project.NameKey> projectNameConverter =
       ProjectNameKeyProtoConverter.INSTANCE;
 
   @Override
-  public Entities.Branch_NameKey toProto(Branch.NameKey nameKey) {
+  public Entities.Branch_NameKey toProto(BranchNameKey nameKey) {
     return Entities.Branch_NameKey.newBuilder()
-        .setProjectName(projectNameConverter.toProto(nameKey.getParentKey()))
-        .setBranchName(nameKey.get())
+        .setProject(projectNameConverter.toProto(nameKey.project()))
+        .setBranch(nameKey.branch())
         .build();
   }
 
   @Override
-  public Branch.NameKey fromProto(Entities.Branch_NameKey proto) {
-    return new Branch.NameKey(
-        projectNameConverter.fromProto(proto.getProjectName()), proto.getBranchName());
+  public BranchNameKey fromProto(Entities.Branch_NameKey proto) {
+    return BranchNameKey.create(
+        projectNameConverter.fromProto(proto.getProject()), proto.getBranch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
index 14ed59c..0d4ec70 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
   INSTANCE;
 
@@ -28,7 +30,7 @@
 
   @Override
   public Change.Id fromProto(Entities.Change_Id proto) {
-    return new Change.Id(proto.getId());
+    return Change.id(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
index dccd7d9..f3ccdfa 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
   INSTANCE;
 
@@ -28,7 +30,7 @@
 
   @Override
   public Change.Key fromProto(Entities.Change_Key proto) {
-    return new Change.Key(proto.getId());
+    return Change.key(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
similarity index 76%
rename from java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
index bb532df..3e93c5a 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeMessageKeyProtoConverter
     implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
   INSTANCE;
@@ -29,14 +31,14 @@
   @Override
   public Entities.ChangeMessage_Key toProto(ChangeMessage.Key messageKey) {
     return Entities.ChangeMessage_Key.newBuilder()
-        .setChangeId(changeIdConverter.toProto(messageKey.getParentKey()))
-        .setUuid(messageKey.get())
+        .setChangeId(changeIdConverter.toProto(messageKey.changeId()))
+        .setUuid(messageKey.uuid())
         .build();
   }
 
   @Override
   public ChangeMessage.Key fromProto(Entities.ChangeMessage_Key proto) {
-    return new ChangeMessage.Key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
+    return ChangeMessage.key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
similarity index 93%
rename from java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index 0895d8d..19c121249 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -12,16 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 import java.sql.Timestamp;
 import java.util.Objects;
 
+@Immutable
 public enum ChangeMessageProtoConverter
     implements ProtoConverter<Entities.ChangeMessage, ChangeMessage> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
similarity index 90%
rename from java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 30b6d27..5b066ea 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -12,16 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 import java.sql.Timestamp;
 
+@Immutable
 public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
   INSTANCE;
 
@@ -31,7 +33,7 @@
       ChangeKeyProtoConverter.INSTANCE;
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
       AccountIdProtoConverter.INSTANCE;
-  private final ProtoConverter<Entities.Branch_NameKey, Branch.NameKey> branchNameConverter =
+  private final ProtoConverter<Entities.Branch_NameKey, BranchNameKey> branchNameConverter =
       BranchNameKeyProtoConverter.INSTANCE;
 
   @Override
@@ -86,7 +88,7 @@
         proto.hasChangeKey() ? changeKeyConverter.fromProto(proto.getChangeKey()) : null;
     Account.Id owner =
         proto.hasOwnerAccountId() ? accountIdConverter.fromProto(proto.getOwnerAccountId()) : null;
-    Branch.NameKey destination =
+    BranchNameKey destination =
         proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
     Change change =
         new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
@@ -100,7 +102,7 @@
     String subject = proto.hasSubject() ? proto.getSubject() : null;
     String originalSubject = proto.hasOriginalSubject() ? proto.getOriginalSubject() : null;
     change.setCurrentPatchSet(
-        new PatchSet.Id(changeId, proto.getCurrentPatchSetId()), subject, originalSubject);
+        PatchSet.id(changeId, proto.getCurrentPatchSetId()), subject, originalSubject);
     if (proto.hasTopic()) {
       change.setTopic(proto.getTopic());
     }
diff --git a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
similarity index 84%
rename from java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
rename to java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
index 274f23b..a1894ac 100644
--- a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
   INSTANCE;
 
@@ -28,7 +30,7 @@
 
   @Override
   public LabelId fromProto(Entities.LabelId proto) {
-    return new LabelId(proto.getId());
+    return LabelId.create(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/entities/converter/ObjectIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ObjectIdProtoConverter.java
new file mode 100644
index 0000000..6c80528
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/ObjectIdProtoConverter.java
@@ -0,0 +1,52 @@
+// 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.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Proto converter for {@code ObjectId}s.
+ *
+ * <p>This converter uses the hex representation of object IDs embedded in a wrapper proto type,
+ * rather than a more parsimonious implementation (e.g. a raw byte array), for two reasons:
+ *
+ * <ul>
+ *   <li>Hex strings are easier to read and work with when reading and writing protos in text
+ *       formats, for example in test failure messages, or when using command-line tools.
+ *   <li>This maintains backwards wire compatibility with a pre-NoteDb implementation.
+ * </ul>
+ */
+@Immutable
+public enum ObjectIdProtoConverter implements ProtoConverter<Entities.ObjectId, ObjectId> {
+  INSTANCE;
+
+  @Override
+  public Entities.ObjectId toProto(ObjectId objectId) {
+    return Entities.ObjectId.newBuilder().setName(objectId.name()).build();
+  }
+
+  @Override
+  public ObjectId fromProto(Entities.ObjectId proto) {
+    return ObjectId.fromString(proto.getName());
+  }
+
+  @Override
+  public Parser<Entities.ObjectId> getParser() {
+    return Entities.ObjectId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
similarity index 75%
rename from java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
rename to java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
index 3538301..c7d1714 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
@@ -12,15 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum PatchSetApprovalKeyProtoConverter
     implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
   INSTANCE;
@@ -35,18 +37,18 @@
   @Override
   public Entities.PatchSetApproval_Key toProto(PatchSetApproval.Key key) {
     return Entities.PatchSetApproval_Key.newBuilder()
-        .setPatchSetId(patchSetIdConverter.toProto(key.getParentKey()))
-        .setAccountId(accountIdConverter.toProto(key.getAccountId()))
-        .setCategoryId(labelIdConverter.toProto(key.getLabelId()))
+        .setPatchSetId(patchSetIdConverter.toProto(key.patchSetId()))
+        .setAccountId(accountIdConverter.toProto(key.accountId()))
+        .setLabelId(labelIdConverter.toProto(key.labelId()))
         .build();
   }
 
   @Override
   public PatchSetApproval.Key fromProto(Entities.PatchSetApproval_Key proto) {
-    return new PatchSetApproval.Key(
+    return PatchSetApproval.key(
         patchSetIdConverter.fromProto(proto.getPatchSetId()),
         accountIdConverter.fromProto(proto.getAccountId()),
-        labelIdConverter.fromProto(proto.getCategoryId()));
+        labelIdConverter.fromProto(proto.getLabelId()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
similarity index 67%
rename from java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
rename to java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
index 418076f..78a35ff 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
@@ -12,15 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.protobuf.Parser;
 import java.sql.Timestamp;
 import java.util.Objects;
 
+@Immutable
 public enum PatchSetApprovalProtoConverter
     implements ProtoConverter<Entities.PatchSetApproval, PatchSetApproval> {
   INSTANCE;
@@ -34,21 +36,18 @@
   public Entities.PatchSetApproval toProto(PatchSetApproval patchSetApproval) {
     Entities.PatchSetApproval.Builder builder =
         Entities.PatchSetApproval.newBuilder()
-            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.getKey()))
-            .setValue(patchSetApproval.getValue())
-            .setGranted(patchSetApproval.getGranted().getTime())
-            .setPostSubmit(patchSetApproval.isPostSubmit());
+            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
+            .setValue(patchSetApproval.value())
+            .setGranted(patchSetApproval.granted().getTime())
+            .setPostSubmit(patchSetApproval.postSubmit());
 
-    String tag = patchSetApproval.getTag();
-    if (tag != null) {
-      builder.setTag(tag);
-    }
-    Account.Id realAccountId = patchSetApproval.getRealAccountId();
+    patchSetApproval.tag().ifPresent(builder::setTag);
+    Account.Id realAccountId = patchSetApproval.realAccountId();
     // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
     // the real author is not set. However, the previous protobuf representation kept
     // 'realAccountId' empty if it wasn't set. To ensure binary compatibility, simulate the previous
     // behavior.
-    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.getAccountId())) {
+    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.accountId())) {
       builder.setRealAccountId(accountIdConverter.toProto(realAccountId));
     }
 
@@ -57,21 +56,19 @@
 
   @Override
   public PatchSetApproval fromProto(Entities.PatchSetApproval proto) {
-    PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()),
-            (short) proto.getValue(),
-            new Timestamp(proto.getGranted()));
+    PatchSetApproval.Builder builder =
+        PatchSetApproval.builder()
+            .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
+            .value(proto.getValue())
+            .granted(new Timestamp(proto.getGranted()))
+            .postSubmit(proto.getPostSubmit());
     if (proto.hasTag()) {
-      patchSetApproval.setTag(proto.getTag());
+      builder.tag(proto.getTag());
     }
     if (proto.hasRealAccountId()) {
-      patchSetApproval.setRealAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
+      builder.realAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
     }
-    if (proto.hasPostSubmit()) {
-      patchSetApproval.setPostSubmit(proto.getPostSubmit());
-    }
-    return patchSetApproval;
+    return builder.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
similarity index 76%
rename from java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
rename to java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
index a2b95bd..60c13f1 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
   INSTANCE;
 
@@ -28,14 +30,14 @@
   @Override
   public Entities.PatchSet_Id toProto(PatchSet.Id patchSetId) {
     return Entities.PatchSet_Id.newBuilder()
-        .setChangeId(changeIdConverter.toProto(patchSetId.getParentKey()))
-        .setPatchSetId(patchSetId.get())
+        .setChangeId(changeIdConverter.toProto(patchSetId.changeId()))
+        .setId(patchSetId.get())
         .build();
   }
 
   @Override
   public PatchSet.Id fromProto(Entities.PatchSet_Id proto) {
-    return new PatchSet.Id(changeIdConverter.fromProto(proto.getChangeId()), proto.getPatchSetId());
+    return PatchSet.id(changeIdConverter.fromProto(proto.getChangeId()), proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
new file mode 100644
index 0000000..13a6e71
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities.converter;
+
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Immutable
+public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter =
+      ObjectIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.PatchSet toProto(PatchSet patchSet) {
+    Entities.PatchSet.Builder builder =
+        Entities.PatchSet.newBuilder()
+            .setId(patchSetIdConverter.toProto(patchSet.id()))
+            .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
+            .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+            .setCreatedOn(patchSet.createdOn().getTime());
+    List<String> groups = patchSet.groups();
+    if (!groups.isEmpty()) {
+      builder.setGroups(PatchSet.joinGroups(groups));
+    }
+    patchSet.pushCertificate().ifPresent(builder::setPushCertificate);
+    patchSet.description().ifPresent(builder::setDescription);
+    return builder.build();
+  }
+
+  @Override
+  public PatchSet fromProto(Entities.PatchSet proto) {
+    PatchSet.Builder builder =
+        PatchSet.builder()
+            .id(patchSetIdConverter.fromProto(proto.getId()))
+            .groups(
+                proto.hasGroups() ? PatchSet.splitGroups(proto.getGroups()) : ImmutableList.of());
+    if (proto.hasPushCertificate()) {
+      builder.pushCertificate(proto.getPushCertificate());
+    }
+    if (proto.hasDescription()) {
+      builder.description(proto.getDescription());
+    }
+
+    // The following fields used to theoretically be nullable in PatchSet, but in practice no
+    // production codepath should have ever serialized an instance that was missing one of these
+    // fields.
+    //
+    // However, since some protos may theoretically be missing these fields, we need to support
+    // them. Populate specific sentinel values for each field as documented in the PatchSet javadoc.
+    // Callers that encounter one of these sentinels will likely fail, for example by failing to
+    // look up the zeroId. They would have also failed back when the fields were nullable, for
+    // example with NPE; the current behavior just fails slightly differently.
+    builder
+        .commitId(
+            proto.hasCommitId()
+                ? objectIdConverter.fromProto(proto.getCommitId())
+                : ObjectId.zeroId())
+        .uploader(
+            proto.hasUploaderAccountId()
+                ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+                : Account.id(0))
+        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+
+    return builder.build();
+  }
+
+  @Override
+  public Parser<Entities.PatchSet> getParser() {
+    return Entities.PatchSet.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
similarity index 85%
rename from java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
index f7d809e..6bb0f79 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ProjectNameKeyProtoConverter
     implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
   INSTANCE;
@@ -29,7 +31,7 @@
 
   @Override
   public Project.NameKey fromProto(Entities.Project_NameKey proto) {
-    return new Project.NameKey(proto.getName());
+    return Project.nameKey(proto.getName());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java b/java/com/google/gerrit/entities/converter/ProtoConverter.java
similarity index 88%
rename from java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
rename to java/com/google/gerrit/entities/converter/ProtoConverter.java
index 568759c..a5c4052 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProtoConverter.java
@@ -12,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.protobuf.MessageLite;
 import com.google.protobuf.Parser;
 
+@Immutable
 public interface ProtoConverter<P extends MessageLite, C> {
 
   P toProto(C valueClass);
diff --git a/java/com/google/gerrit/exceptions/BUILD b/java/com/google/gerrit/exceptions/BUILD
index e08c3fd..ef59be1 100644
--- a/java/com/google/gerrit/exceptions/BUILD
+++ b/java/com/google/gerrit/exceptions/BUILD
@@ -4,5 +4,5 @@
     name = "exceptions",
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//java/com/google/gerrit/reviewdb:server"],
+    deps = ["//java/com/google/gerrit/entities"],
 )
diff --git a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java b/java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
similarity index 62%
copy from java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
copy to java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
index 568759c..d9c5776 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
+++ b/java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2018 The Android Open Source Project
+// 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.
@@ -12,16 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.exceptions;
 
-import com.google.protobuf.MessageLite;
-import com.google.protobuf.Parser;
+public class InvalidMergeStrategyException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface ProtoConverter<P extends MessageLite, C> {
-
-  P toProto(C valueClass);
-
-  C fromProto(P proto);
-
-  Parser<P> getParser();
+  public InvalidMergeStrategyException(String strategy) {
+    super("invalid merge strategy: " + strategy);
+  }
 }
diff --git a/java/com/google/gerrit/exceptions/NoSuchGroupException.java b/java/com/google/gerrit/exceptions/NoSuchGroupException.java
index dca28cb..95efac3 100644
--- a/java/com/google/gerrit/exceptions/NoSuchGroupException.java
+++ b/java/com/google/gerrit/exceptions/NoSuchGroupException.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.exceptions;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 /** Indicates the account group does not exist. */
 public class NoSuchGroupException extends Exception {
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index 0022584..da5dc8b 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,8 +1,11 @@
 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")
 
+_DOC_VERS = "5.5.0.201909110433-r"
+
+JGIT_DOC_URL = "https://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
+
 java_binary(
     name = "extension-api",
     main_class = "Dummy",
@@ -16,14 +19,13 @@
     exports = [
         ":api",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
     ],
 )
 
-#TODO(davido): There is no provided_deps argument to java_library rule
 java_library(
     name = "api",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3d5fccc..f0462c6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -209,6 +210,10 @@
     return suggestReviewers().withQuery(query);
   }
 
+  default SuggestedReviewersRequest suggestCcs(String query) throws RestApiException {
+    return suggestReviewers().forCc().withQuery(query);
+  }
+
   /**
    * Retrieve reviewers ({@code ReviewerState.REVIEWER} and {@code ReviewerState.CC}) on the change.
    */
@@ -241,12 +246,16 @@
    * <ul>
    *   <li>{@code CHECK} is omitted, to skip consistency checks.
    *   <li>{@code SKIP_MERGEABLE} is omitted, so the {@code mergeable} bit <em>is</em> set.
+   *   <li>{@code SKIP_DIFFSTAT} is omitted to ensure diffstat calculations.
    * </ul>
    */
   default ChangeInfo get() throws RestApiException {
     return get(
         EnumSet.complementOf(
-            EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_MERGEABLE)));
+            EnumSet.of(
+                ListChangesOption.CHECK,
+                ListChangesOption.SKIP_MERGEABLE,
+                ListChangesOption.SKIP_DIFFSTAT)));
   }
 
   /** {@link #get(ListChangesOption...)} with no options included. */
@@ -377,6 +386,8 @@
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
+    private boolean excludeGroups;
+    private ReviewerState reviewerState = ReviewerState.REVIEWER;
 
     public abstract List<SuggestedReviewerInfo> get() throws RestApiException;
 
@@ -390,6 +401,16 @@
       return this;
     }
 
+    public SuggestedReviewersRequest excludeGroups(boolean excludeGroups) {
+      this.excludeGroups = excludeGroups;
+      return this;
+    }
+
+    public SuggestedReviewersRequest forCc() {
+      this.reviewerState = ReviewerState.CC;
+      return this;
+    }
+
     public String getQuery() {
       return query;
     }
@@ -397,6 +418,14 @@
     public int getLimit() {
       return limit;
     }
+
+    public boolean getExcludeGroups() {
+      return excludeGroups;
+    }
+
+    public ReviewerState getReviewerState() {
+      return reviewerState;
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index ef49651..dd29635 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -14,13 +14,38 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.base.MoreObjects;
 import java.util.List;
+import java.util.Objects;
 
 /** Detailed information about who should be notified about an update. */
 public class NotifyInfo {
   public List<String> accounts;
 
+  /**
+   * @param accounts may be either just a list of: account IDs, Full names, usernames, or emails.
+   *     Also could be a list of those: "Full name <email@example.com>" or "Full name (<ID>)"
+   */
   public NotifyInfo(List<String> accounts) {
     this.accounts = accounts;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof NotifyInfo)) {
+      return false;
+    }
+    NotifyInfo other = (NotifyInfo) o;
+    return Objects.equals(other.accounts, accounts);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(accounts);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("accounts", accounts).toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index c1be9b0..c4272e4 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -24,4 +24,6 @@
   public NotifyHandling notify = NotifyHandling.ALL;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  public String topic;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 68575ca..f854d4a 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -136,7 +136,7 @@
 
   SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException;
 
-  List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException;
+  TestSubmitRuleInfo testSubmitRule(TestSubmitRuleInput in) throws RestApiException;
 
   MergeListRequest getMergeList() throws RestApiException;
 
@@ -341,7 +341,7 @@
     }
 
     @Override
-    public List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
+    public TestSubmitRuleInfo testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/groups/GroupInput.java b/java/com/google/gerrit/extensions/api/groups/GroupInput.java
index ab38434..30af08f 100644
--- a/java/com/google/gerrit/extensions/api/groups/GroupInput.java
+++ b/java/com/google/gerrit/extensions/api/groups/GroupInput.java
@@ -18,6 +18,7 @@
 
 public class GroupInput {
   public String name;
+  public String uuid;
   public String description;
   public Boolean visibleToAll;
   public String ownerId;
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 3d70996..c6d9dee 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -201,6 +201,9 @@
    */
   void index(boolean indexChildren) throws RestApiException;
 
+  /** Reindexes all changes of the project. */
+  void indexChanges() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -370,5 +373,10 @@
     public void index(boolean indexChildren) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void indexChanges() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 3bca4bb..d5fbf89 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -30,7 +30,9 @@
   public String path;
   public Side side;
   public Integer parent;
-  public Integer line; // value 0 or null indicates a file comment, normal lines start at 1
+  /** Value 0 or null indicates a file comment, normal lines start at 1. */
+  public Integer line;
+
   public Range range;
   public String inReplyTo;
   public Timestamp updated;
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 652abcc..ed01a4d 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -38,7 +38,7 @@
     IGNORE_NONE,
     IGNORE_TRAILING,
     IGNORE_LEADING_AND_TRAILING,
-    IGNORE_ALL;
+    IGNORE_ALL
   }
 
   public Integer context;
@@ -73,17 +73,12 @@
     i.fontSize = DEFAULT_FONT_SIZE;
     i.lineLength = DEFAULT_LINE_LENGTH;
     i.cursorBlinkRate = 0;
-    i.ignoreWhitespace = Whitespace.IGNORE_NONE;
     i.expandAllComments = false;
     i.intralineDifference = true;
     i.manualReview = false;
-    i.retainHeader = false;
     i.showLineEndings = true;
     i.showTabs = true;
     i.showWhitespaceErrors = true;
-    i.skipDeleted = false;
-    i.skipUnchanged = false;
-    i.skipUncommented = false;
     i.syntaxHighlighting = true;
     i.hideTopMenu = false;
     i.autoHideDiffTableHeader = true;
@@ -92,6 +87,11 @@
     i.hideEmptyPane = false;
     i.matchBrackets = false;
     i.lineWrapping = false;
+    i.ignoreWhitespace = Whitespace.IGNORE_NONE;
+    i.retainHeader = false;
+    i.skipDeleted = false;
+    i.skipUnchanged = false;
+    i.skipUncommented = false;
     return i;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 8eb54e1..212f6da 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -67,14 +67,6 @@
     }
   }
 
-  public enum ReviewCategoryStrategy {
-    NONE,
-    NAME,
-    EMAIL,
-    USERNAME,
-    ABBREV
-  }
-
   public enum DiffView {
     SIDE_BY_SIDE,
     UNIFIED_DIFF
@@ -130,10 +122,6 @@
 
   /** Number of changes to show in a screen. */
   public Integer changesPerPage;
-  /** Should the site header be displayed when logged in ? */
-  public Boolean showSiteHeader;
-  /** Should the Flash helper movie be used to copy text to the clipboard? */
-  public Boolean useFlashClipboard;
   /** Type of download URL the user prefers to use. */
   public String downloadScheme;
 
@@ -145,20 +133,15 @@
   public DiffView diffView;
   public Boolean sizeBarInChangeTable;
   public Boolean legacycidInChangeTable;
-  public ReviewCategoryStrategy reviewCategoryStrategy;
   public Boolean muteCommonPathPrefixes;
   public Boolean signedOffBy;
-  public List<MenuItem> my;
-  public List<String> changeTable;
   public EmailStrategy emailStrategy;
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
   public Boolean workInProgressByDefault;
-
-  public boolean isShowInfoInReviewCategory() {
-    return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
-  }
+  public List<MenuItem> my;
+  public List<String> changeTable;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -174,13 +157,6 @@
     return timeFormat;
   }
 
-  public ReviewCategoryStrategy getReviewCategoryStrategy() {
-    if (reviewCategoryStrategy == null) {
-      return ReviewCategoryStrategy.NONE;
-    }
-    return reviewCategoryStrategy;
-  }
-
   public DiffView getDiffView() {
     if (diffView == null) {
       return DiffView.SIDE_BY_SIDE;
@@ -205,11 +181,6 @@
   public static GeneralPreferencesInfo defaults() {
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
-    p.showSiteHeader = true;
-    p.useFlashClipboard = true;
-    p.emailStrategy = EmailStrategy.ENABLED;
-    p.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    p.reviewCategoryStrategy = ReviewCategoryStrategy.NONE;
     p.downloadScheme = null;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
@@ -221,6 +192,8 @@
     p.legacycidInChangeTable = false;
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
+    p.emailStrategy = EmailStrategy.ENABLED;
+    p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
     p.workInProgressByDefault = false;
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index c842adc..425265b 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -75,7 +75,13 @@
   TRACKING_IDS(21),
 
   /** Skip mergeability data */
-  SKIP_MERGEABLE(22);
+  SKIP_MERGEABLE(22),
+
+  /**
+   * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+   * deletions field (number of lines deleted)
+   */
+  SKIP_DIFFSTAT(23);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index a498ab0..3ffa97a 100644
--- a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -18,7 +18,6 @@
 
 public class AccountDetailInfo extends AccountInfo {
   public Timestamp registeredOn;
-  public Boolean inactive;
 
   public AccountDetailInfo(Integer id) {
     super(id);
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index f20509b..d1bbe88 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.base.MoreObjects;
 import java.util.List;
 import java.util.Objects;
 
@@ -26,6 +27,7 @@
   public List<AvatarInfo> avatars;
   public Boolean _moreAccounts;
   public String status;
+  public Boolean inactive;
 
   public AccountInfo(Integer id) {
     this._accountId = id;
@@ -54,6 +56,16 @@
   }
 
   @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("id", _accountId)
+        .add("name", name)
+        .add("email", email)
+        .add("username", username)
+        .toString();
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(
         _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 00f1819..de609eb 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -21,7 +21,7 @@
    * <p>The web UI prefers avatar images to be square, both the height and width of the image should
    * be this size. The height is the more important dimension to match than the width.
    */
-  public static final int DEFAULT_SIZE = 26;
+  public static final int DEFAULT_SIZE = 32;
 
   public String url;
   public Integer height;
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 1e822e3..e8aeb40 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -23,4 +23,5 @@
   public String replyTooltip;
   public int updateDelay;
   public Boolean submitWholeTopic;
+  public Boolean excludeMergeableInChangeInfo;
 }
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 3e6f762..711337a 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
   public enum Type {
@@ -30,35 +31,35 @@
 
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Timestamp date, AccountInfo member) {
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Timestamp date, GroupInfo member) {
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
-  protected GroupAuditEventInfo(Type type, AccountInfo user, Timestamp date) {
+  protected GroupAuditEventInfo(Type type, AccountInfo user, Optional<Timestamp> date) {
     this.type = type;
     this.user = user;
-    this.date = date;
+    this.date = date.orElse(null);
   }
 
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
-    public UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, AccountInfo member) {
+    private UserMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -67,8 +68,8 @@
   public static class GroupMemberAuditEventInfo extends GroupAuditEventInfo {
     public GroupInfo member;
 
-    public GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, GroupInfo member) {
+    private GroupMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 9cecb66..c976de0 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -9,7 +9,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index f0f5516..d6fcb37 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.truth.ListSubject;
 
-public class CommitInfoSubject extends Subject<CommitInfoSubject, CommitInfo> {
+public class CommitInfoSubject extends Subject {
 
   public static CommitInfoSubject assertThat(CommitInfo commitInfo) {
     return assertAbout(commits()).that(commitInfo);
@@ -34,37 +34,35 @@
     return CommitInfoSubject::new;
   }
 
+  private final CommitInfo commitInfo;
+
   private CommitInfoSubject(FailureMetadata failureMetadata, CommitInfo commitInfo) {
     super(failureMetadata, commitInfo);
+    this.commitInfo = commitInfo;
   }
 
   public StringSubject commit() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("commit()").that(commitInfo.commit);
+    return check("commit").that(commitInfo.commit);
   }
 
   public ListSubject<CommitInfoSubject, CommitInfo> parents() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("parents()").about(elements()).thatCustom(commitInfo.parents, commits());
+    return check("parents").about(elements()).thatCustom(commitInfo.parents, commits());
   }
 
   public GitPersonSubject committer() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("committer()").about(gitPersons()).that(commitInfo.committer);
+    return check("committer").about(gitPersons()).that(commitInfo.committer);
   }
 
   public GitPersonSubject author() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("author()").about(gitPersons()).that(commitInfo.author);
+    return check("author").about(gitPersons()).that(commitInfo.author);
   }
 
   public StringSubject message() {
     isNotNull();
-    CommitInfo commitInfo = actual();
     return check("message").that(commitInfo.message);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 25750c1..b55f7c2 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
 import com.google.gerrit.truth.ListSubject;
 
-public class ContentEntrySubject extends Subject<ContentEntrySubject, ContentEntry> {
+public class ContentEntrySubject extends Subject {
 
   public static ContentEntrySubject assertThat(ContentEntry contentEntry) {
     return assertAbout(contentEntries()).that(contentEntry);
@@ -37,13 +37,15 @@
     return ContentEntrySubject::new;
   }
 
+  private final ContentEntry contentEntry;
+
   private ContentEntrySubject(FailureMetadata failureMetadata, ContentEntry contentEntry) {
     super(failureMetadata, contentEntry);
+    this.contentEntry = contentEntry;
   }
 
   public void isDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     if (contentEntry.dueToRebase == null || !contentEntry.dueToRebase) {
       failWithActual(simpleFact("expected entry to be marked 'dueToRebase'"));
     }
@@ -51,7 +53,6 @@
 
   public void isNotDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     if (contentEntry.dueToRebase != null && contentEntry.dueToRebase) {
       failWithActual(simpleFact("expected entry not to be marked 'dueToRebase'"));
     }
@@ -59,7 +60,6 @@
 
   public ListSubject<StringSubject, String> commonLines() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("commonLines()")
         .about(elements())
         .that(contentEntry.ab, StandardSubjectBuilder::that);
@@ -67,31 +67,26 @@
 
   public ListSubject<StringSubject, String> linesOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("linesOfA()").about(elements()).that(contentEntry.a, StandardSubjectBuilder::that);
   }
 
   public ListSubject<StringSubject, String> linesOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("linesOfB()").about(elements()).that(contentEntry.b, StandardSubjectBuilder::that);
   }
 
   public IterableSubject intralineEditsOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("intralineEditsOfA()").that(contentEntry.editA);
   }
 
   public IterableSubject intralineEditsOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("intralineEditsOfB()").that(contentEntry.editB);
   }
 
   public IntegerSubject numberOfSkippedLines() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("numberOfSkippedLines()").that(contentEntry.skip);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index ee37bde..8853a30 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -26,39 +26,38 @@
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
 import com.google.gerrit.truth.ListSubject;
 
-public class DiffInfoSubject extends Subject<DiffInfoSubject, DiffInfo> {
+public class DiffInfoSubject extends Subject {
 
   public static DiffInfoSubject assertThat(DiffInfo diffInfo) {
     return assertAbout(DiffInfoSubject::new).that(diffInfo);
   }
 
+  private final DiffInfo diffInfo;
+
   private DiffInfoSubject(FailureMetadata failureMetadata, DiffInfo diffInfo) {
     super(failureMetadata, diffInfo);
+    this.diffInfo = diffInfo;
   }
 
   public ListSubject<ContentEntrySubject, ContentEntry> content() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("content()")
+    return check("content")
         .about(elements())
         .thatCustom(diffInfo.content, ContentEntrySubject.contentEntries());
   }
 
-  public ComparableSubject<?, ChangeType> changeType() {
+  public ComparableSubject<ChangeType> changeType() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("changeType()").that(diffInfo.changeType);
+    return check("changeType").that(diffInfo.changeType);
   }
 
   public FileMetaSubject metaA() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("metaA()").about(fileMetas()).that(diffInfo.metaA);
+    return check("metaA").about(fileMetas()).that(diffInfo.metaA);
   }
 
   public FileMetaSubject metaB() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("metaB()").about(fileMetas()).that(diffInfo.metaB);
+    return check("metaB").about(fileMetas()).that(diffInfo.metaB);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
index 1c99141..b5622e0 100644
--- a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
 
-public class EditInfoSubject extends Subject<EditInfoSubject, EditInfo> {
+public class EditInfoSubject extends Subject {
 
   public static EditInfoSubject assertThat(EditInfo editInfo) {
     return assertAbout(edits()).that(editInfo);
@@ -39,19 +39,20 @@
     return OptionalSubject.assertThat(editInfoOptional, edits());
   }
 
+  private final EditInfo editInfo;
+
   private EditInfoSubject(FailureMetadata failureMetadata, EditInfo editInfo) {
     super(failureMetadata, editInfo);
+    this.editInfo = editInfo;
   }
 
   public CommitInfoSubject commit() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return check("commit()").about(commits()).that(editInfo.commit);
+    return check("commit").about(commits()).that(editInfo.commit);
   }
 
   public StringSubject baseRevision() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return check("baseRevision()").that(editInfo.baseRevision);
+    return check("baseRevision").that(editInfo.baseRevision);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index 3ebf838..d011d5d 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -22,31 +22,31 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.FileInfo;
 
-public class FileInfoSubject extends Subject<FileInfoSubject, FileInfo> {
+public class FileInfoSubject extends Subject {
 
   public static FileInfoSubject assertThat(FileInfo fileInfo) {
     return assertAbout(FileInfoSubject::new).that(fileInfo);
   }
 
+  private final FileInfo fileInfo;
+
   private FileInfoSubject(FailureMetadata failureMetadata, FileInfo fileInfo) {
     super(failureMetadata, fileInfo);
+    this.fileInfo = fileInfo;
   }
 
   public IntegerSubject linesInserted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("linesInserted()").that(fileInfo.linesInserted);
+    return check("linesInserted").that(fileInfo.linesInserted);
   }
 
   public IntegerSubject linesDeleted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("linesDeleted()").that(fileInfo.linesDeleted);
+    return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
-  public ComparableSubject<?, Character> status() {
+  public ComparableSubject<Character> status() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("status()").that(fileInfo.status);
+    return check("status").that(fileInfo.status);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
index d1b2031..fb09a1f 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -21,7 +21,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
 
-public class FileMetaSubject extends Subject<FileMetaSubject, FileMeta> {
+public class FileMetaSubject extends Subject {
 
   public static FileMetaSubject assertThat(FileMeta fileMeta) {
     return assertAbout(fileMetas()).that(fileMeta);
@@ -31,13 +31,15 @@
     return FileMetaSubject::new;
   }
 
+  private final FileMeta fileMeta;
+
   private FileMetaSubject(FailureMetadata failureMetadata, FileMeta fileMeta) {
     super(failureMetadata, fileMeta);
+    this.fileMeta = fileMeta;
   }
 
   public IntegerSubject totalLineCount() {
     isNotNull();
-    FileMeta fileMeta = actual();
     return check("totalLineCount()").that(fileMeta.lines);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
index 6ba5f8b..9ba69dc 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
@@ -22,8 +22,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 
-public class FixReplacementInfoSubject
-    extends Subject<FixReplacementInfoSubject, FixReplacementInfo> {
+public class FixReplacementInfoSubject extends Subject {
 
   public static FixReplacementInfoSubject assertThat(FixReplacementInfo fixReplacementInfo) {
     return assertAbout(fixReplacements()).that(fixReplacementInfo);
@@ -33,23 +32,26 @@
     return FixReplacementInfoSubject::new;
   }
 
+  private final FixReplacementInfo fixReplacementInfo;
+
   private FixReplacementInfoSubject(
       FailureMetadata failureMetadata, FixReplacementInfo fixReplacementInfo) {
     super(failureMetadata, fixReplacementInfo);
+    this.fixReplacementInfo = fixReplacementInfo;
   }
 
   public StringSubject path() {
     isNotNull();
-    return check("path()").that(actual().path);
+    return check("path").that(fixReplacementInfo.path);
   }
 
   public RangeSubject range() {
     isNotNull();
-    return check("range()").about(ranges()).that(actual().range);
+    return check("range").about(ranges()).that(fixReplacementInfo.range);
   }
 
   public StringSubject replacement() {
     isNotNull();
-    return check("replacement()").that(actual().replacement);
+    return check("replacement").that(fixReplacementInfo.replacement);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
index 98dac38..4ac725a 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
@@ -21,12 +21,11 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.truth.ListSubject;
 
-public class FixSuggestionInfoSubject extends Subject<FixSuggestionInfoSubject, FixSuggestionInfo> {
+public class FixSuggestionInfoSubject extends Subject {
 
   public static FixSuggestionInfoSubject assertThat(FixSuggestionInfo fixSuggestionInfo) {
     return assertAbout(fixSuggestions()).that(fixSuggestionInfo);
@@ -36,20 +35,23 @@
     return FixSuggestionInfoSubject::new;
   }
 
+  private final FixSuggestionInfo fixSuggestionInfo;
+
   private FixSuggestionInfoSubject(
       FailureMetadata failureMetadata, FixSuggestionInfo fixSuggestionInfo) {
     super(failureMetadata, fixSuggestionInfo);
+    this.fixSuggestionInfo = fixSuggestionInfo;
   }
 
   public StringSubject fixId() {
-    return Truth.assertThat(actual().fixId).named("fixId");
+    return check("fixId").that(fixSuggestionInfo.fixId);
   }
 
   public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
     isNotNull();
-    return check("replacements()")
+    return check("replacements")
         .about(elements())
-        .thatCustom(actual().replacements, fixReplacements());
+        .thatCustom(fixSuggestionInfo.replacements, fixReplacements());
   }
 
   public FixReplacementInfoSubject onlyReplacement() {
@@ -58,6 +60,6 @@
 
   public StringSubject description() {
     isNotNull();
-    return check("description()").that(actual().description);
+    return check("description").that(fixSuggestionInfo.description);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index dee0636..d827d5d 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.extensions.common.testing;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
@@ -27,7 +27,7 @@
 import java.util.Date;
 import org.eclipse.jgit.lib.PersonIdent;
 
-public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
+public class GitPersonSubject extends Subject {
 
   public static GitPersonSubject assertThat(GitPerson gitPerson) {
     return assertAbout(gitPersons()).that(gitPerson);
@@ -37,36 +37,35 @@
     return GitPersonSubject::new;
   }
 
+  private final GitPerson gitPerson;
+
   private GitPersonSubject(FailureMetadata failureMetadata, GitPerson gitPerson) {
     super(failureMetadata, gitPerson);
+    this.gitPerson = gitPerson;
   }
 
   public StringSubject name() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("name()").that(gitPerson.name);
+    return check("name").that(gitPerson.name);
   }
 
   public StringSubject email() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("email()").that(gitPerson.email);
+    return check("email").that(gitPerson.email);
   }
 
-  public ComparableSubject<?, Timestamp> date() {
+  public ComparableSubject<Timestamp> date() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("date()").that(gitPerson.date);
+    return check("date").that(gitPerson.date);
   }
 
   public IntegerSubject tz() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("tz()").that(gitPerson.tz);
+    return check("tz").that(gitPerson.tz);
   }
 
   public void hasSameDateAs(GitPerson other) {
-    checkNotNull(other, "'other' GitPerson must not be null");
+    requireNonNull(other, "'other' GitPerson must not be null");
     isNotNull();
     date().isEqualTo(other.date);
     tz().isEqualTo(other.tz);
@@ -76,7 +75,7 @@
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(new Date(actual().date.getTime())).isEqualTo(ident.getWhen());
+    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
index 12acb8d..10abca2 100644
--- a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
@@ -22,7 +22,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.client.Comment;
 
-public class RangeSubject extends Subject<RangeSubject, Comment.Range> {
+public class RangeSubject extends Subject {
 
   public static RangeSubject assertThat(Comment.Range range) {
     return assertAbout(ranges()).that(range);
@@ -32,36 +32,39 @@
     return RangeSubject::new;
   }
 
+  private final Comment.Range range;
+
   private RangeSubject(FailureMetadata failureMetadata, Comment.Range range) {
     super(failureMetadata, range);
+    this.range = range;
   }
 
   public IntegerSubject startLine() {
-    return check("startLine()").that(actual().startLine);
+    return check("startLine").that(range.startLine);
   }
 
   public IntegerSubject startCharacter() {
-    return check("startCharacter()").that(actual().startCharacter);
+    return check("startCharacter").that(range.startCharacter);
   }
 
   public IntegerSubject endLine() {
-    return check("endLine()").that(actual().endLine);
+    return check("endLine").that(range.endLine);
   }
 
   public IntegerSubject endCharacter() {
-    return check("endCharacter()").that(actual().endCharacter);
+    return check("endCharacter").that(range.endCharacter);
   }
 
   public void isValid() {
     isNotNull();
-    if (!actual().isValid()) {
+    if (!range.isValid()) {
       failWithActual(simpleFact("expected to be valid"));
     }
   }
 
   public void isInvalid() {
     isNotNull();
-    if (actual().isValid()) {
+    if (range.isValid()) {
       failWithActual(simpleFact("expected to be invalid"));
     }
   }
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index 033f54b..0698735 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -24,11 +24,11 @@
 import com.google.gerrit.truth.ListSubject;
 import java.util.List;
 
-public class RobotCommentInfoSubject extends Subject<RobotCommentInfoSubject, RobotCommentInfo> {
+public class RobotCommentInfoSubject extends Subject {
 
   public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
       List<RobotCommentInfo> robotCommentInfos) {
-    return ListSubject.assertThat(robotCommentInfos, robotComments()).named("robotCommentInfos");
+    return ListSubject.assertThat(robotCommentInfos, robotComments());
   }
 
   public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
@@ -39,15 +39,18 @@
     return RobotCommentInfoSubject::new;
   }
 
+  private final RobotCommentInfo robotCommentInfo;
+
   private RobotCommentInfoSubject(
       FailureMetadata failureMetadata, RobotCommentInfo robotCommentInfo) {
     super(failureMetadata, robotCommentInfo);
+    this.robotCommentInfo = robotCommentInfo;
   }
 
   public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
-    return check("fixSuggestions()")
+    return check("fixSuggestions")
         .about(elements())
-        .thatCustom(actual().fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
+        .thatCustom(robotCommentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
   }
 
   public FixSuggestionInfoSubject onlyFixSuggestion() {
diff --git a/java/com/google/gerrit/extensions/events/ChangeEvent.java b/java/com/google/gerrit/extensions/events/ChangeEvent.java
index 0b51052..def75b7 100644
--- a/java/com/google/gerrit/extensions/events/ChangeEvent.java
+++ b/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -20,6 +20,11 @@
 
 /** Interface to be extended by Events with a Change. */
 public interface ChangeEvent extends GerritEvent {
+  /**
+   * Information about the change. Some fields might be null.
+   *
+   * @see com.google.gerrit.server.extensions.events.EventUtil
+   */
   ChangeInfo getChange();
 
   AccountInfo getWho();
diff --git a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
index 8dd64ed..64bc022 100644
--- a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
+++ b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
@@ -20,6 +20,21 @@
 @ExtensionPoint
 public interface ChangeIndexedListener {
   /**
+   * Invoked when a change is scheduled for indexing.
+   *
+   * @param projectName project containing the change
+   * @param id id of the change that was scheduled for indexing
+   */
+  default void onChangeScheduledForIndexing(String projectName, int id) {}
+
+  /**
+   * Invoked when a change is scheduled for deletion from indexing.
+   *
+   * @param id id of the change that was scheduled for deletion from indexing
+   */
+  default void onChangeScheduledForDeletionFromIndex(int id) {}
+
+  /**
    * Invoked when a change is indexed.
    *
    * @param projectName project containing the change
diff --git a/java/com/google/gerrit/extensions/events/RevisionEvent.java b/java/com/google/gerrit/extensions/events/RevisionEvent.java
index f0cfa2c..db7830e 100644
--- a/java/com/google/gerrit/extensions/events/RevisionEvent.java
+++ b/java/com/google/gerrit/extensions/events/RevisionEvent.java
@@ -18,5 +18,11 @@
 
 /** Interface to be extended by Events with a Revision. */
 public interface RevisionEvent extends ChangeEvent {
+
+  /**
+   * Information about the revision. Some fields might be null.
+   *
+   * @see com.google.gerrit.server.extensions.events.EventUtil
+   */
   RevisionInfo getRevision();
 }
diff --git a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
index fa2288a..270a040 100644
--- a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
+++ b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
@@ -38,9 +38,8 @@
     super("Not found: " + id.get(), cause);
   }
 
-  @SuppressWarnings("unchecked")
-  @Override
   public ResourceNotFoundException caching(CacheControl c) {
-    return super.caching(c);
+    setCaching(c);
+    return this;
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index 8f2dd5f..5504cfd 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
@@ -51,24 +55,48 @@
     return new Redirect(location);
   }
 
+  /**
+   * HTTP 500 Internal Server Error: failure due to an unexpected exception.
+   *
+   * <p>Can be returned from REST endpoints, instead of throwing the exception, if additional
+   * properties (e.g. a traceId) should be set on the response.
+   *
+   * @param cause the exception that caused the request to fail, must not be a {@link
+   *     RestApiException} because such an exception would result in a 4XX response code
+   */
+  public static <T> InternalServerError<T> internalServerError(Exception cause) {
+    return new InternalServerError<>(cause);
+  }
+
   /** Arbitrary status code with wrapped result. */
   public static <T> Response<T> withStatusCode(int statusCode, T value) {
     return new Impl<>(statusCode, value);
   }
 
   @SuppressWarnings({"unchecked", "rawtypes"})
-  public static <T> T unwrap(T obj) {
+  public static <T> T unwrap(T obj) throws Exception {
     while (obj instanceof Response) {
       obj = (T) ((Response) obj).value();
     }
     return obj;
   }
 
+  private String traceId;
+
+  public Response<T> traceId(@Nullable String traceId) {
+    this.traceId = traceId;
+    return this;
+  }
+
+  public Optional<String> traceId() {
+    return Optional.ofNullable(traceId);
+  }
+
   public abstract boolean isNone();
 
   public abstract int statusCode();
 
-  public abstract T value();
+  public abstract T value() throws Exception;
 
   public abstract CacheControl caching();
 
@@ -154,13 +182,38 @@
   }
 
   /** An HTTP redirect to another location. */
-  public static final class Redirect {
+  public static final class Redirect extends Response<Object> {
     private final String location;
 
     private Redirect(String url) {
       this.location = url;
     }
 
+    @Override
+    public boolean isNone() {
+      return false;
+    }
+
+    @Override
+    public int statusCode() {
+      return 302;
+    }
+
+    @Override
+    public Object value() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public CacheControl caching() {
+      return CacheControl.NONE;
+    }
+
+    @Override
+    public Response<Object> caching(CacheControl c) {
+      throw new UnsupportedOperationException();
+    }
+
     public String location() {
       return location;
     }
@@ -182,13 +235,38 @@
   }
 
   /** Accepted as task for asynchronous execution. */
-  public static final class Accepted {
+  public static final class Accepted extends Response<Object> {
     private final String location;
 
     private Accepted(String url) {
       this.location = url;
     }
 
+    @Override
+    public boolean isNone() {
+      return false;
+    }
+
+    @Override
+    public int statusCode() {
+      return 202;
+    }
+
+    @Override
+    public Object value() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public CacheControl caching() {
+      return CacheControl.NONE;
+    }
+
+    @Override
+    public Response<Object> caching(CacheControl c) {
+      throw new UnsupportedOperationException();
+    }
+
     public String location() {
       return location;
     }
@@ -208,4 +286,57 @@
       return String.format("[202 Accepted] %s", location);
     }
   }
+
+  public static final class InternalServerError<T> extends Response<T> {
+    private final Exception cause;
+
+    private InternalServerError(Exception cause) {
+      checkArgument(!(cause instanceof RestApiException), "cause must not be a RestApiException");
+      this.cause = cause;
+    }
+
+    @Override
+    public boolean isNone() {
+      return false;
+    }
+
+    @Override
+    public int statusCode() {
+      return 500;
+    }
+
+    @Override
+    public T value() throws Exception {
+      throw cause();
+    }
+
+    @Override
+    public CacheControl caching() {
+      return CacheControl.NONE;
+    }
+
+    @Override
+    public Response<T> caching(CacheControl c) {
+      throw new UnsupportedOperationException();
+    }
+
+    public Exception cause() {
+      return cause;
+    }
+
+    @Override
+    public int hashCode() {
+      return cause.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return o instanceof InternalServerError && ((InternalServerError<?>) o).cause.equals(cause);
+    }
+
+    @Override
+    public String toString() {
+      return String.format("[500 Internal Server Error] %s", cause.getClass());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiException.java b/java/com/google/gerrit/extensions/restapi/RestApiException.java
index b09723e..f3d7dec 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static java.util.Objects.requireNonNull;
+
 /** Root exception type for REST API failures. */
 public class RestApiException extends Exception {
   private static final long serialVersionUID = 1L;
@@ -33,9 +35,7 @@
     return caching;
   }
 
-  @SuppressWarnings("unchecked")
-  public <T extends RestApiException> T caching(CacheControl c) {
-    caching = c;
-    return (T) this;
+  protected void setCaching(CacheControl caching) {
+    this.caching = requireNonNull(caching);
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
index 25cdb76..72ca74b 100644
--- a/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
@@ -33,13 +33,24 @@
   /**
    * Process the view operation by creating the resource.
    *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionCreateViews this is usually {@code 201 Created} because a resource is created,
+   * but other 2XX or 3XX status codes are also possible (e.g. {@link Response.Redirect} can be
+   * returned for {@code 302 Found}).
+   *
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
    * @param parentResource parent resource of the resource that should be created
+   * @param id the ID of the child resource that should be created
    * @param input input after parsing from request.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
+   * @return response to return to the client
    * @throws RestApiException if the resource creation is rejected
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(P parentResource, IdString id, I input) throws Exception;
+  Response<?> apply(P parentResource, IdString id, I input) throws Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
index 7e5649c..c08d06a 100644
--- a/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
@@ -37,13 +37,25 @@
   /**
    * Process the view operation by deleting the resource.
    *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionDeleteMissingViews this is usually {@code 204 No Content} because a resource is
+   * deleted, but other 2XX or 3XX status codes are also possible (e.g. {@code 200 OK}, {@code 302
+   * Found} for a redirect).
+   *
+   * <p>The returned response usually does not have any value (status code {@code 204 No Content}).
+   * If a value in the returned response is set it is automatically converted to JSON unless it is a
+   * {@link BinaryResult}.
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
    * @param parentResource parent resource of the resource that should be deleted
-   * @param input input after parsing from request.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
+   * @param id the ID of the child resource that should be deleted
+   * @param input input after parsing from request
+   * @return response to return to the client
    * @throws RestApiException if the resource creation is rejected
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(P parentResource, IdString id, I input) throws Exception;
+  Response<?> apply(P parentResource, IdString id, I input) throws Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
index acabf96..fcaa15b 100644
--- a/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
@@ -28,5 +28,25 @@
 public interface RestCollectionModifyView<P extends RestResource, C extends RestResource, I>
     extends RestCollectionView<P, C, I> {
 
-  Object apply(P parentResource, I input) throws Exception;
+  /**
+   * Process the modification on the collection resource.
+   *
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionModifyViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are
+   * also possible (e.g. {@code 201 Created} if a resource was created, {@code 202 Accepted} if a
+   * background task was scheduled, {@code 204 No Content} if no content is returned, {@code 302
+   * Found} for a redirect).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param parentResource the collection resource on which the modification is done
+   * @return response to return to the client
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Response<?> apply(P parentResource, I input) throws Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestModifyView.java b/java/com/google/gerrit/extensions/restapi/RestModifyView.java
index 79053dd..e397bd0 100644
--- a/java/com/google/gerrit/extensions/restapi/RestModifyView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestModifyView.java
@@ -28,11 +28,21 @@
   /**
    * Process the view operation by altering the resource.
    *
-   * @param resource resource to modify.
-   * @param input input after parsing from request.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
-   * @throws AuthException the client is not permitted to access this view.
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestModifyViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are also
+   * possible (e.g. {@code 202 Accepted} if a background task was scheduled, {@code 204 No Content}
+   * if no content is returned, {@code 302 Found} for a redirect).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param resource resource to modify
+   * @param input input after parsing from request
+   * @return response to return to the client
+   * @throws AuthException the caller is not permitted to access this view.
    * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
    *     view.
    * @throws ResourceConflictException the resource state does not permit this view to make the
@@ -40,6 +50,6 @@
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource, I input)
+  Response<?> apply(R resource, I input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestReadView.java b/java/com/google/gerrit/extensions/restapi/RestReadView.java
index a3c31d3..8991f0b 100644
--- a/java/com/google/gerrit/extensions/restapi/RestReadView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestReadView.java
@@ -17,16 +17,27 @@
 /**
  * RestView to read a resource without modification.
  *
+ * <p>RestReadViews are invoked by the HTTP GET method.
+ *
  * @param <R> type of resource the view reads.
  */
 public interface RestReadView<R extends RestResource> extends RestView<R> {
   /**
    * Process the view operation by reading from the resource.
    *
-   * @param resource resource to read.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
-   * @throws AuthException the client is not permitted to access this view.
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestReadViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are also
+   * possible (e.g. {@link Response.Redirect} can be returned for {@code 302 Found}).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param resource resource to read
+   * @return response to return to the client
+   * @throws AuthException the caller is not permitted to access this view.
    * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
    *     view.
    * @throws ResourceConflictException the resource state does not permit this view to make the
@@ -34,6 +45,6 @@
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource)
+  Response<?> apply(R resource)
       throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
index 5109205..c5304e3 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
+++ b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
@@ -26,7 +26,7 @@
 import java.io.IOException;
 import java.util.Optional;
 
-public class BinaryResultSubject extends Subject<BinaryResultSubject, BinaryResult> {
+public class BinaryResultSubject extends Subject {
 
   public static BinaryResultSubject assertThat(BinaryResult binaryResult) {
     return assertAbout(binaryResults()).that(binaryResult);
@@ -41,8 +41,11 @@
     return OptionalSubject.assertThat(binaryResultOptional, binaryResults());
   }
 
+  private final BinaryResult binaryResult;
+
   private BinaryResultSubject(FailureMetadata failureMetadata, BinaryResult binaryResult) {
     super(failureMetadata, binaryResult);
+    this.binaryResult = binaryResult;
   }
 
   public StringSubject asString() throws IOException {
@@ -50,7 +53,6 @@
     // We shouldn't close the BinaryResult within this method as it might still
     // be used afterwards. Besides, closing it doesn't have an effect for most
     // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
     return check("asString()").that(binaryResult.asString());
   }
 
@@ -59,7 +61,6 @@
     // We shouldn't close the BinaryResult within this method as it might still
     // be used afterwards. Besides, closing it doesn't have an effect for most
     // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
     ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
     binaryResult.writeTo(byteArrayOutputStream);
     byte[] bytes = byteArrayOutputStream.toByteArray();
diff --git a/java/com/google/gerrit/extensions/validators/CommentForValidation.java b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
new file mode 100644
index 0000000..51ae5ae
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.validators;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Holds a comment's text and {@link CommentType} in order to pass it to a validation plugin.
+ *
+ * @see CommentValidator
+ */
+@AutoValue
+public abstract class CommentForValidation {
+
+  /** The type of comment. */
+  public enum CommentType {
+    /** A regular (inline) comment. */
+    INLINE_COMMENT,
+    /** A file comment. */
+    FILE_COMMENT,
+    /** A change message. */
+    CHANGE_MESSAGE
+  }
+
+  public static CommentForValidation create(CommentType type, String text) {
+    return new AutoValue_CommentForValidation(type, text);
+  }
+
+  public abstract CommentType getType();
+
+  public abstract String getText();
+
+  public CommentValidationFailure failValidation(String message) {
+    return CommentValidationFailure.create(this, message);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java b/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java
new file mode 100644
index 0000000..1a832760
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.validators;
+
+import com.google.auto.value.AutoValue;
+
+/** A comment or review message was rejected by a {@link CommentValidator}. */
+@AutoValue
+public abstract class CommentValidationFailure {
+  static CommentValidationFailure create(
+      CommentForValidation commentForValidation, String message) {
+    return new AutoValue_CommentValidationFailure(commentForValidation, message);
+  }
+
+  /** Returns the offending comment. */
+  public abstract CommentForValidation getComment();
+
+  /** A friendly message set by the {@link CommentValidator}. */
+  public abstract String getMessage();
+}
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidator.java b/java/com/google/gerrit/extensions/validators/CommentValidator.java
new file mode 100644
index 0000000..cfefdef
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentValidator.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Validates review comments and messages. Rejecting any comment/message will prevent all comments
+ * from being published.
+ */
+@ExtensionPoint
+public interface CommentValidator {
+
+  /**
+   * Validate the specified comments.
+   *
+   * @return An empty list if all comments are valid, or else a list of validation failures.
+   */
+  ImmutableList<CommentValidationFailure> validateComments(
+      ImmutableList<CommentForValidation> comments);
+}
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index 4c4d5bc..0574716 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -5,7 +5,10 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
     ],
 )
diff --git a/java/com/google/gerrit/git/GitUpdateFailureException.java b/java/com/google/gerrit/git/GitUpdateFailureException.java
new file mode 100644
index 0000000..76ef217
--- /dev/null
+++ b/java/com/google/gerrit/git/GitUpdateFailureException.java
@@ -0,0 +1,95 @@
+// 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.git;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Thrown when updating a ref in Git fails. */
+public class GitUpdateFailureException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  private final ImmutableList<GitUpdateFailure> failures;
+
+  public GitUpdateFailureException(String message, RefUpdate refUpdate) {
+    super(message);
+    this.failures = ImmutableList.of(GitUpdateFailure.create(refUpdate));
+  }
+
+  public GitUpdateFailureException(String message, BatchRefUpdate batchRefUpdate) {
+    super(message);
+    this.failures =
+        batchRefUpdate.getCommands().stream()
+            .filter(c -> c.getResult() != ReceiveCommand.Result.OK)
+            .map(GitUpdateFailure::create)
+            .collect(toImmutableList());
+  }
+
+  /** @return the names of the refs for which the update failed. */
+  public ImmutableList<String> getFailedRefs() {
+    return failures.stream().map(GitUpdateFailure::ref).collect(toImmutableList());
+  }
+
+  /** @return the failures that caused this exception. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ImmutableList<GitUpdateFailure> getFailures() {
+    return failures;
+  }
+
+  @AutoValue
+  public abstract static class GitUpdateFailure {
+    private static GitUpdateFailure create(RefUpdate refUpdate) {
+      return builder().ref(refUpdate.getName()).result(refUpdate.getResult().name()).build();
+    }
+
+    private static GitUpdateFailure create(ReceiveCommand receiveCommand) {
+      return builder()
+          .ref(receiveCommand.getRefName())
+          .result(receiveCommand.getResult().name())
+          .message(receiveCommand.getMessage())
+          .build();
+    }
+
+    public abstract String ref();
+
+    public abstract String result();
+
+    public abstract Optional<String> message();
+
+    public static GitUpdateFailure.Builder builder() {
+      return new AutoValue_GitUpdateFailureException_GitUpdateFailure.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder ref(String ref);
+
+      abstract Builder result(String result);
+
+      abstract Builder message(@Nullable String message);
+
+      abstract GitUpdateFailure build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/git/LockFailureException.java b/java/com/google/gerrit/git/LockFailureException.java
index 9e67d70..371488d 100644
--- a/java/com/google/gerrit/git/LockFailureException.java
+++ b/java/com/google/gerrit/git/LockFailureException.java
@@ -14,36 +14,18 @@
 
 package com.google.gerrit.git;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import java.io.IOException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
-public class LockFailureException extends IOException {
+public class LockFailureException extends GitUpdateFailureException {
   private static final long serialVersionUID = 1L;
 
-  private final ImmutableList<String> refs;
-
   public LockFailureException(String message, RefUpdate refUpdate) {
-    super(message);
-    refs = ImmutableList.of(refUpdate.getName());
+    super(message, refUpdate);
   }
 
   public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
-    super(message);
-    refs =
-        batchRefUpdate.getCommands().stream()
-            .filter(c -> c.getResult() == ReceiveCommand.Result.LOCK_FAILURE)
-            .map(ReceiveCommand::getRefName)
-            .collect(toImmutableList());
-  }
-
-  /** Subset of ref names that caused the lock failure. */
-  public ImmutableList<String> getFailedRefs() {
-    return refs;
+    super(message, batchRefUpdate);
   }
 }
diff --git a/java/com/google/gerrit/git/ObjectIds.java b/java/com/google/gerrit/git/ObjectIds.java
new file mode 100644
index 0000000..4d83046
--- /dev/null
+++ b/java/com/google/gerrit/git/ObjectIds.java
@@ -0,0 +1,131 @@
+// 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.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+/** Static utilities for working with {@code ObjectId}s. */
+public class ObjectIds {
+  /** Length of a binary SHA-1 byte array. */
+  public static final int LEN = Constants.OBJECT_ID_LENGTH;
+
+  /** Length of a hex SHA-1 string. */
+  public static final int STR_LEN = Constants.OBJECT_ID_STRING_LENGTH;
+
+  /** Default abbreviated length of a hex SHA-1 string. */
+  public static final int ABBREV_STR_LEN = 7;
+
+  /**
+   * Abbreviate an ID's hex string representation to 7 chars.
+   *
+   * @param id object ID.
+   * @return abbreviated hex string representation, exactly 7 chars.
+   */
+  public static String abbreviateName(AnyObjectId id) {
+    return abbreviateName(id, ABBREV_STR_LEN);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation to {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n number of hex chars, 1 to 40.
+   * @return abbreviated hex string representation, exactly {@code n} chars.
+   */
+  public static String abbreviateName(AnyObjectId id, int n) {
+    checkValidLength(n);
+    return requireNonNull(id).abbreviate(n).name();
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least 7 chars.
+   *
+   * @param id object ID.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least 7
+   *     chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, ObjectReader reader) throws IOException {
+    return abbreviateName(id, ABBREV_STR_LEN, reader);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n minimum number of hex chars, 1 to 40.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least
+   *     {@code n} chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, int n, ObjectReader reader)
+      throws IOException {
+    checkValidLength(n);
+    return reader.abbreviate(id, n).name();
+  }
+
+  /**
+   * Copy a nullable ID, preserving null.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@code null} if {@code id} is null.
+   */
+  @Nullable
+  public static ObjectId copyOrNull(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : null;
+  }
+
+  /**
+   * Copy a nullable ID, converting null to {@code zeroId}.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@link ObjectId#zeroId} if {@code id} is
+   *     null.
+   */
+  public static ObjectId copyOrZero(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : ObjectId.zeroId();
+  }
+
+  /**
+   * Return whether the given ID matches the given abbreviation.
+   *
+   * @param id object ID.
+   * @param abbreviation abbreviated hex object ID. May not be null, but may be an invalid hex SHA-1
+   *     abbreviation string.
+   * @return true if {@code id} is not null and {@code abbreviation} is a valid hex SHA-1
+   *     abbreviation that matches {@code id}, false otherwise.
+   */
+  public static boolean matchesAbbreviation(@Nullable AnyObjectId id, String abbreviation) {
+    requireNonNull(abbreviation);
+    return id != null && id.name().startsWith(abbreviation);
+  }
+
+  private static void checkValidLength(int n) {
+    checkArgument(n > 0);
+    checkArgument(n <= STR_LEN);
+  }
+
+  private ObjectIds() {}
+}
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index 5eaf253..bd88962 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -99,7 +99,7 @@
     if (lockFailure + aborted == bru.getCommands().size()) {
       throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
     } else if (failure > 0) {
-      throw new IOException("Update failed: " + bru);
+      throw new GitUpdateFailureException("Update failed: " + bru, bru);
     }
   }
 
@@ -130,7 +130,8 @@
       case REJECTED_CURRENT_BRANCH:
       case REJECTED_MISSING_OBJECT:
       case REJECTED_OTHER_REASON:
-        throw new IOException("Failed to update " + ru.getName() + ": " + ru.getResult());
+        throw new GitUpdateFailureException(
+            "Failed to update " + ru.getName() + ": " + ru.getResult(), ru);
     }
   }
 
@@ -175,7 +176,8 @@
       case REJECTED_MISSING_OBJECT:
       case REJECTED_OTHER_REASON:
       default:
-        throw new IOException("Failed to delete " + refName + ": " + ru.getResult());
+        throw new GitUpdateFailureException(
+            "Failed to delete " + refName + ": " + ru.getResult(), ru);
     }
   }
 
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
index 13fddc1..fda9aff 100644
--- a/java/com/google/gerrit/git/testing/BUILD
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -9,7 +9,7 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
diff --git a/java/com/google/gerrit/git/testing/CommitSubject.java b/java/com/google/gerrit/git/testing/CommitSubject.java
index 0873107..41eb45b 100644
--- a/java/com/google/gerrit/git/testing/CommitSubject.java
+++ b/java/com/google/gerrit/git/testing/CommitSubject.java
@@ -24,7 +24,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /** Subject over JGit {@link RevCommit}s. */
-public class CommitSubject extends Subject<CommitSubject, RevCommit> {
+public class CommitSubject extends Subject {
 
   /**
    * Constructs a new subject.
@@ -56,8 +56,11 @@
     commitSubject.hasSha1(expectedSha1);
   }
 
-  private CommitSubject(FailureMetadata metadata, RevCommit actual) {
-    super(metadata, actual);
+  private final RevCommit commit;
+
+  private CommitSubject(FailureMetadata metadata, RevCommit commit) {
+    super(metadata, commit);
+    this.commit = commit;
   }
 
   /**
@@ -67,8 +70,7 @@
    */
   public void hasCommitMessage(String expectedCommitMessage) {
     isNotNull();
-    RevCommit commit = actual();
-    check("commitMessage()").that(commit.getFullMessage()).isEqualTo(expectedCommitMessage);
+    check("getFullMessage()").that(commit.getFullMessage()).isEqualTo(expectedCommitMessage);
   }
 
   /**
@@ -78,7 +80,6 @@
    */
   public void hasCommitTimestamp(Timestamp expectedCommitTimestamp) {
     isNotNull();
-    RevCommit commit = actual();
     long timestampDiffMs =
         Math.abs(commit.getCommitTime() * 1000L - expectedCommitTimestamp.getTime());
     check("commitTimestampDiff()").that(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
@@ -91,7 +92,6 @@
    */
   public void hasSha1(ObjectId expectedSha1) {
     isNotNull();
-    RevCommit commit = actual();
     check("sha1()").that(commit).isEqualTo(expectedSha1);
   }
 }
diff --git a/java/com/google/gerrit/git/testing/ObjectIdSubject.java b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
index 5fe91f9..0cfc563 100644
--- a/java/com/google/gerrit/git/testing/ObjectIdSubject.java
+++ b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
@@ -20,7 +20,7 @@
 import com.google.common.truth.Subject;
 import org.eclipse.jgit.lib.ObjectId;
 
-public class ObjectIdSubject extends Subject<ObjectIdSubject, ObjectId> {
+public class ObjectIdSubject extends Subject {
   public static ObjectIdSubject assertThat(ObjectId objectId) {
     return assertAbout(objectIds()).that(objectId);
   }
@@ -29,13 +29,15 @@
     return ObjectIdSubject::new;
   }
 
-  private ObjectIdSubject(FailureMetadata metadata, ObjectId actual) {
-    super(metadata, actual);
+  private final ObjectId objectId;
+
+  private ObjectIdSubject(FailureMetadata metadata, ObjectId objectId) {
+    super(metadata, objectId);
+    this.objectId = objectId;
   }
 
   public void hasName(String expectedName) {
     isNotNull();
-    ObjectId objectId = actual();
-    check("name()").that(objectId.getName()).isEqualTo(expectedName);
+    check("getName()").that(objectId.getName()).isEqualTo(expectedName);
   }
 }
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index f5c9810..9a46632 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.git.testing;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.git.testing.PushResultSubject.RemoteRefUpdateSubject.refs;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
@@ -26,42 +28,44 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StreamSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.common.Nullable;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 
-public class PushResultSubject extends Subject<PushResultSubject, PushResult> {
+public class PushResultSubject extends Subject {
   public static PushResultSubject assertThat(PushResult actual) {
     return assertAbout(PushResultSubject::new).that(actual);
   }
 
-  private PushResultSubject(FailureMetadata metadata, PushResult actual) {
-    super(metadata, actual);
+  private final PushResult pushResult;
+
+  private PushResultSubject(FailureMetadata metadata, PushResult pushResult) {
+    super(metadata, pushResult);
+    this.pushResult = pushResult;
   }
 
   public void hasNoMessages() {
-    check("hasNoMessages()")
-        .withMessage("expected no messages")
-        .that(Strings.nullToEmpty(trimMessages()))
-        .isEqualTo("");
+    isNotNull();
+    check("hasNoMessages()").that(Strings.nullToEmpty(getTrimmedMessages())).isEqualTo("");
   }
 
   public void hasMessages(String... expectedLines) {
     checkArgument(expectedLines.length > 0, "use hasNoMessages()");
     isNotNull();
-    check("messages()").that(trimMessages()).isEqualTo(String.join("\n", expectedLines));
+    check("getTrimmedMessages()")
+        .that(getTrimmedMessages())
+        .isEqualTo(String.join("\n", expectedLines));
   }
 
   public void containsMessages(String... expectedLines) {
     checkArgument(expectedLines.length > 0, "use hasNoMessages()");
     isNotNull();
-    Iterable<String> got = Splitter.on("\n").split(trimMessages());
-    check("messages()").that(got).containsAtLeastElementsIn(expectedLines).inOrder();
+    Iterable<String> got = Splitter.on("\n").split(getTrimmedMessages());
+    check("getTrimmedMessages()").that(got).containsAtLeastElementsIn(expectedLines).inOrder();
   }
 
-  private String trimMessages() {
-    return trimMessages(actual().getMessages());
+  private String getTrimmedMessages() {
+    return trimMessages(pushResult.getMessages());
   }
 
   @VisibleForTesting
@@ -78,15 +82,16 @@
   }
 
   public void hasProcessed(ImmutableMap<String, Integer> expected) {
+    isNotNull();
     ImmutableMap<String, Integer> actual;
-    String messages = actual().getMessages();
+    String messages = pushResult.getMessages();
     try {
       actual = parseProcessed(messages);
     } catch (RuntimeException e) {
-      Truth.assert_()
-          .fail(
-              "failed to parse \"Processing changes\" line from messages: %s\n%s",
-              messages, Throwables.getStackTraceAsString(e));
+      failWithActual(
+          fact(
+              "failed to parse \"Processing changes\" line from messages, reason:",
+              Throwables.getStackTraceAsString(e)));
       return;
     }
     check("processedCommands()").that(actual).containsExactlyEntriesIn(expected).inOrder();
@@ -119,57 +124,60 @@
   }
 
   public RemoteRefUpdateSubject ref(String refName) {
-    return assertAbout(
-            (FailureMetadata m, RemoteRefUpdate a) -> new RemoteRefUpdateSubject(refName, m, a))
-        .that(actual().getRemoteUpdate(refName));
+    isNotNull();
+    return check("getRemoteUpdate(%s)", refName)
+        .about(refs())
+        .that(pushResult.getRemoteUpdate(refName));
   }
 
   public RemoteRefUpdateSubject onlyRef(String refName) {
+    isNotNull();
     check("setOfRefs()")
         .about(StreamSubject.streams())
-        .that(actual().getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
-        .named("set of refs")
+        .that(pushResult.getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
         .containsExactly(refName);
     return ref(refName);
   }
 
-  public static class RemoteRefUpdateSubject
-      extends Subject<RemoteRefUpdateSubject, RemoteRefUpdate> {
-    private final String refName;
+  public static class RemoteRefUpdateSubject extends Subject {
+    private final RemoteRefUpdate remoteRefUpdate;
 
-    private RemoteRefUpdateSubject(
-        String refName, FailureMetadata metadata, RemoteRefUpdate actual) {
-      super(metadata, actual);
-      this.refName = refName;
-      named("ref update for %s", refName).isNotNull();
+    private RemoteRefUpdateSubject(FailureMetadata metadata, RemoteRefUpdate remoteRefUpdate) {
+      super(metadata, remoteRefUpdate);
+      this.remoteRefUpdate = remoteRefUpdate;
+    }
+
+    static Factory<RemoteRefUpdateSubject, RemoteRefUpdate> refs() {
+      return RemoteRefUpdateSubject::new;
     }
 
     public void hasStatus(RemoteRefUpdate.Status status) {
-      RemoteRefUpdate u = actual();
-      Truth.assertThat(u.getStatus())
-          .named(
-              "status of ref update for %s%s",
-              refName, u.getMessage() != null ? ": " + u.getMessage() : "")
+      isNotNull();
+      RemoteRefUpdate u = remoteRefUpdate;
+      check("getStatus()")
+          .withMessage(
+              "status message: %s", u.getMessage() != null ? ": " + u.getMessage() : "<emtpy>")
+          .that(u.getStatus())
           .isEqualTo(status);
     }
 
     public void hasNoMessage() {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isNull();
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isNull();
     }
 
     public void hasMessage(String expected) {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isEqualTo(expected);
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isEqualTo(expected);
     }
 
     public void isOk() {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.OK);
     }
 
     public void isRejected(String expectedMessage) {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
       hasMessage(expectedMessage);
     }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index f11b9b9..9f804c4 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -5,18 +5,18 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/api",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 9c08857..9477cb6 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -201,7 +201,7 @@
   private Set<String> getAllowedUserIds(IdentifiedUser user) {
     Set<String> result = new HashSet<>();
     result.addAll(user.getEmailAddresses());
-    for (ExternalId extId : user.state().getExternalIds()) {
+    for (ExternalId extId : user.state().externalIds()) {
       if (extId.isScheme(SCHEME_GPGKEY)) {
         continue; // Omit GPG keys.
       }
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 7dd01d9..519c400 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -16,10 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -45,7 +47,6 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -75,9 +76,6 @@
  * after checking with a {@link PublicKeyChecker}.
  */
 public class PublicKeyStore implements AutoCloseable {
-  private static final ObjectId EMPTY_TREE =
-      ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
-
   /** Ref where GPG public keys are stored. */
   public static final String REFS_GPG_KEYS = "refs/meta/gpg-keys";
 
@@ -360,7 +358,7 @@
         deleteFromNotes(ins, fp);
       }
       cb.setTreeId(notes.writeTree(ins));
-      if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE)) {
+      if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE_ID)) {
         return RefUpdate.Result.NO_CHANGE;
       }
 
@@ -516,7 +514,7 @@
   }
 
   static ObjectId keyObjectId(long keyId) {
-    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    byte[] buf = new byte[ObjectIds.LEN];
     NB.encodeInt64(buf, 0, keyId);
     return ObjectId.fromRaw(buf);
   }
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index a051861..c11c4e3 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -16,9 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index b7b03db..652afea 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.gpg.api;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -65,9 +67,11 @@
   public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException {
     try {
-      return gpgKeys.get().list().apply(account);
+      return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list GPG keys", e);
     }
   }
 
@@ -79,9 +83,11 @@
     in.add = add;
     in.delete = delete;
     try {
-      return postGpgKeys.get().apply(account, in);
+      return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index cf09acf..0ff12e8 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.gpg.api;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -46,9 +48,9 @@
   @Override
   public GpgKeyInfo get() throws RestApiException {
     try {
-      return get.apply(rsrc);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get GPG key", e);
+      return get.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get GPG key", e);
     }
   }
 
@@ -57,7 +59,7 @@
     try {
       delete.apply(rsrc, new Input());
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete GPG key", e);
+      throw asRestApiException("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 68bd2d9..1be37f5 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -105,7 +105,7 @@
           } catch (EmailException e) {
             logger.atSevere().withCause(e).log(
                 "Cannot send GPG key deletion message to %s",
-                rsrc.getUser().getAccount().getPreferredEmail());
+                rsrc.getUser().getAccount().preferredEmail());
           }
           break;
         case LOCK_FAILURE:
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index 16592f8..b3a2f53 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.gpg.BouncyCastleUtil;
@@ -140,7 +141,7 @@
 
   public class ListGpgKeys implements RestReadView<AccountResource> {
     @Override
-    public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
+    public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
         throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
       Map<String, GpgKeyInfo> keys = new HashMap<>();
@@ -165,7 +166,7 @@
           }
         }
       }
-      return keys;
+      return Response.ok(keys);
     }
   }
 
@@ -181,12 +182,13 @@
     }
 
     @Override
-    public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
+    public Response<GpgKeyInfo> apply(GpgKey rsrc) throws IOException {
       try (PublicKeyStore store = storeProvider.get()) {
-        return toJson(
-            rsrc.getKeyRing().getPublicKey(),
-            checkerFactory.create().setExpectedUser(rsrc.getUser()),
-            store);
+        return Response.ok(
+            toJson(
+                rsrc.getKeyRing().getPublicKey(),
+                checkerFactory.create().setExpectedUser(rsrc.getUser()),
+                store));
       }
     }
   }
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 0bb4ff7..5396e1c 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -18,15 +18,18 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
@@ -34,13 +37,15 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.gpg.CheckResult;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -53,6 +58,8 @@
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -87,6 +94,7 @@
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final RetryHelper retryHelper;
 
   @Inject
   PostGpgKeys(
@@ -98,7 +106,8 @@
       DeleteKeySender.Factory deleteKeySenderFactory,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
-      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      RetryHelper retryHelper) {
     this.serverIdent = serverIdent;
     this.self = self;
     this.storeProvider = storeProvider;
@@ -108,12 +117,12 @@
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.retryHelper = retryHelper;
   }
 
   @Override
-  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
-          PGPException, IOException, ConfigInvalidException {
+  public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc, GpgKeysInput input)
+      throws RestApiException, PGPException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
@@ -129,7 +138,7 @@
         ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
         Account account = getAccountByExternalId(extIdKey);
         if (account != null) {
-          if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+          if (!account.id().equals(rsrc.getUser().getAccountId())) {
             throw new ResourceConflictException("GPG key already associated with another account");
           }
         } else {
@@ -145,7 +154,7 @@
               "Update GPG Keys via API",
               rsrc.getUser().getAccountId(),
               u -> u.replaceExternalIds(toRemove.keySet(), newExtIds));
-      return toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser());
+      return Response.ok(toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser()));
     }
   }
 
@@ -196,7 +205,24 @@
 
   private void storeKeys(
       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
-      throws BadRequestException, PGPException, IOException {
+      throws RestApiException, PGPException, IOException {
+    try {
+      retryHelper.execute(
+          ActionType.ACCOUNT_UPDATE,
+          () -> tryStoreKeys(rsrc, keyRings, toRemove),
+          LockFailureException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, PGPException.class);
+      throw new StorageException(e);
+    }
+  }
+
+  private Void tryStoreKeys(
+      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
+      throws RestApiException, PGPException, IOException {
     try (PublicKeyStore store = storeProvider.get()) {
       List<String> addedKeys = new ArrayList<>();
       IdentifiedUser user = rsrc.getUser();
@@ -232,7 +258,7 @@
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
-                  rsrc.getUser().getAccount().getPreferredEmail());
+                  rsrc.getUser().getAccount().preferredEmail());
             }
           }
           if (!toRemove.isEmpty()) {
@@ -242,8 +268,7 @@
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
-                  "Cannot send GPG key deleted message to %s",
-                  user.getAccount().getPreferredEmail());
+                  "Cannot send GPG key deleted message to %s", user.getAccount().preferredEmail());
             }
           }
           break;
@@ -261,6 +286,7 @@
           throw new StorageException(String.format("Failed to save public keys: %s", saveResult));
       }
     }
+    return null;
   }
 
   private ExternalId.Key toExtIdKey(byte[] fp) {
@@ -275,15 +301,15 @@
     }
 
     if (accountStates.size() > 1) {
-      StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ")
-          .append(extIdKey.get())
-          .append(" associated with multiple accounts: ")
-          .append(Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      throw new IllegalStateException(msg.toString());
+      String msg = "GPG key " + extIdKey.get() + " associated with multiple accounts: [";
+      msg =
+          accountStates.stream()
+              .map(a -> a.account().id().toString())
+              .collect(joining(", ", msg, "]"));
+      throw new IllegalStateException(msg);
     }
 
-    return accountStates.get(0).getAccount();
+    return accountStates.get(0).account();
   }
 
   private Map<String, GpgKeyInfo> toJson(
diff --git a/java/com/google/gerrit/gpg/testing/BUILD b/java/com/google/gerrit/gpg/testing/BUILD
index b227dd5..dc39071 100644
--- a/java/com/google/gerrit/gpg/testing/BUILD
+++ b/java/com/google/gerrit/gpg/testing/BUILD
@@ -8,7 +8,7 @@
     deps = [
         "//java/com/google/gerrit/gpg",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/bouncycastle:bcpg-neverlink",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java b/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
index 68bbd98..af8234f 100644
--- a/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
+++ b/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.httpd;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 
 @AutoValue
 abstract class AdvertisedObjectsCacheKey {
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 96c56c8..bcb2a2a 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -12,14 +12,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
@@ -32,8 +31,10 @@
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-servlet",
         "//lib:jsch",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
@@ -43,7 +44,5 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index bbe15b5..7f878aa 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -18,11 +18,11 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.httpd.restapi.ParameterParser;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
diff --git a/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
index d13f2f6..517d5db 100644
--- a/java/com/google/gerrit/httpd/ContainerAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
@@ -112,13 +112,13 @@
       username = username.toLowerCase(Locale.US);
     }
     Optional<AccountState> who =
-        accountCache.getByUsername(username).filter(a -> a.getAccount().isActive());
+        accountCache.getByUsername(username).filter(a -> a.account().isActive());
     if (!who.isPresent()) {
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
     WebSession ws = session.get();
-    ws.setUserAccountId(who.get().getAccount().getId());
+    ws.setUserAccountId(who.get().account().id());
     ws.setAccessPathOk(AccessPath.GIT, true);
     ws.setAccessPathOk(AccessPath.REST_API, true);
     return true;
diff --git a/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
index 152a83d..a9be2aa 100644
--- a/java/com/google/gerrit/httpd/DirectChangeByCommit.java
+++ b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
@@ -6,11 +6,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -47,7 +47,7 @@
       // If exactly one change matches, link to that change.
       // TODO Link to a specific patch set, if one matched.
       ChangeInfo ci = results.iterator().next();
-      token = PageLinks.toChange(new Project.NameKey(ci.project), new Change.Id(ci._number));
+      token = PageLinks.toChange(Project.nameKey(ci.project), Change.id(ci._number));
     } else {
       // Otherwise, link to the query page.
       token = PageLinks.toChangeQuery(query);
diff --git a/java/com/google/gerrit/httpd/GitOverHttpModule.java b/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 8400d60..cbcfb0b 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.httpd.GitOverHttpServlet.URL_REGEX;
 
-import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
+import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index c97ee10..eb6b2e0 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -20,23 +20,27 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.HttpAuditEvent;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.PermissionAwareRepositoryManager;
+import com.google.gerrit.server.git.TracingHook;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.UsersSelfAdvertiseRefsHook;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -51,6 +55,7 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.time.Duration;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
@@ -279,7 +284,7 @@
       user.setAccessPath(AccessPath.GIT);
 
       try {
-        Project.NameKey nameKey = new Project.NameKey(projectName);
+        Project.NameKey nameKey = Project.nameKey(projectName);
         ProjectState state = projectCache.checkedGet(nameKey);
         if (state == null || !state.statePermitsRead()) {
           throw new RepositoryNotFoundException(nameKey.get());
@@ -307,27 +312,38 @@
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final DynamicSet<PostUploadHook> postUploadHooks;
     private final PluginSetContext<UploadPackInitializer> uploadPackInitializers;
+    private final PermissionBackend permissionBackend;
 
     @Inject
     UploadFactory(
         TransferConfig tc,
         DynamicSet<PreUploadHook> preUploadHooks,
         DynamicSet<PostUploadHook> postUploadHooks,
-        PluginSetContext<UploadPackInitializer> uploadPackInitializers) {
+        PluginSetContext<UploadPackInitializer> uploadPackInitializers,
+        PermissionBackend permissionBackend) {
       this.config = tc;
       this.preUploadHooks = preUploadHooks;
       this.postUploadHooks = postUploadHooks;
       this.uploadPackInitializers = uploadPackInitializers;
+      this.permissionBackend = permissionBackend;
     }
 
     @Override
     public UploadPack create(HttpServletRequest req, Repository repo) {
-      UploadPack up = new UploadPack(repo);
+      ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
+      UploadPack up =
+          new UploadPack(
+              PermissionAwareRepositoryManager.wrap(
+                  repo, permissionBackend.currentUser().project(state.getNameKey())));
       up.setPackConfig(config.getPackConfig());
       up.setTimeout(config.getTimeout());
       up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
       up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
-      ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
+      String header = req.getHeader("Git-Protocol");
+      if (header != null) {
+        String[] params = header.split(":");
+        up.setExtraParameters(Arrays.asList(params));
+      }
       uploadPackInitializers.runEach(initializer -> initializer.init(state.getNameKey(), up));
       return up;
     }
@@ -339,6 +355,8 @@
     private final Provider<CurrentUser> userProvider;
     private final GroupAuditService groupAuditService;
     private final Metrics metrics;
+    private final PluginSetContext<RequestListener> requestListeners;
+    private final UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook;
 
     @Inject
     UploadFilter(
@@ -346,12 +364,16 @@
         PermissionBackend permissionBackend,
         Provider<CurrentUser> userProvider,
         GroupAuditService groupAuditService,
-        Metrics metrics) {
+        Metrics metrics,
+        PluginSetContext<RequestListener> requestListeners,
+        UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook) {
       this.uploadValidatorsFactory = uploadValidatorsFactory;
       this.permissionBackend = permissionBackend;
       this.userProvider = userProvider;
       this.groupAuditService = groupAuditService;
       this.metrics = metrics;
+      this.requestListeners = requestListeners;
+      this.usersSelfAdvertiseRefsHook = usersSelfAdvertiseRefsHook;
     }
 
     @Override
@@ -369,7 +391,14 @@
       HttpServletRequest httpRequest = (HttpServletRequest) request;
       String sessionId = httpRequest.getSession().getId();
 
-      try {
+      try (TraceContext traceContext = TraceContext.open()) {
+        RequestInfo requestInfo =
+            RequestInfo.builder(
+                    RequestInfo.RequestType.GIT_UPLOAD, userProvider.get(), traceContext)
+                .project(state.getNameKey())
+                .build();
+        requestListeners.runEach(l -> l.onRequest(requestInfo));
+
         try {
           perm.check(ProjectPermission.RUN_UPLOAD_PACK);
         } catch (AuthException e) {
@@ -391,8 +420,14 @@
         up.setPreUploadHook(
             PreUploadHookChain.newChain(
                 Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-        up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
-        next.doFilter(httpRequest, responseWrapper);
+        if (state.isAllUsers()) {
+          up.setAdvertiseRefsHook(usersSelfAdvertiseRefsHook);
+        }
+
+        try (TracingHook tracingHook = new TracingHook()) {
+          up.setProtocolV2Hook(tracingHook);
+          next.doFilter(httpRequest, responseWrapper);
+        }
       } finally {
         groupAuditService.dispatch(
             new HttpAuditEvent(
diff --git a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
index 397d093..4ae7e5e 100644
--- a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
+++ b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -67,7 +67,7 @@
     headers.put(name, value);
   }
 
-  @SuppressWarnings("all")
+  @SuppressWarnings({"all", "MissingOverride"})
   // @Override is omitted for backwards compatibility with servlet-api 2.5
   // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
   //       to servlet-api 3.1
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index d53a5c5..89ad878 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.httpd;
 
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
@@ -61,7 +61,7 @@
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     } catch (PermissionBackendException | RuntimeException e) {
-      throw new IOException("Unable to lookup change " + id.id, e);
+      throw new IOException("Unable to lookup change " + id.get(), e);
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index eb9d1d7..e75d8fe 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -21,9 +21,9 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.AuthenticationFailedException;
+import com.google.gerrit.server.account.externalids.PasswordVerifier;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.AuthConfig;
@@ -130,7 +131,7 @@
     }
 
     Optional<AccountState> accountState =
-        accountCache.getByUsername(username).filter(a -> a.getAccount().isActive());
+        accountCache.getByUsername(username).filter(a -> a.account().isActive());
     if (!accountState.isPresent()) {
       logger.atWarning().log(
           "Authentication failed for %s: account inactive or not provisioned in Gerrit", username);
@@ -142,7 +143,7 @@
     GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
         || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
-      if (who.checkPassword(password, username)) {
+      if (PasswordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who);
       }
     }
@@ -159,7 +160,7 @@
       setUserIdentified(whoAuthResult.getAccountId());
       return true;
     } catch (NoSuchUserException e) {
-      if (who.checkPassword(password, username)) {
+      if (PasswordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who);
       }
       logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
@@ -183,7 +184,7 @@
   }
 
   private boolean succeedAuthentication(AccountState who) {
-    setUserIdentified(who.getAccount().getId());
+    setUserIdentified(who.account().id());
     return true;
   }
 
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 4461a52..8300823 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -22,11 +22,11 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
@@ -152,7 +152,7 @@
     }
 
     Optional<AccountState> who =
-        accountCache.getByUsername(authInfo.username).filter(a -> a.getAccount().isActive());
+        accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive());
     if (!who.isPresent()) {
       logger.atWarning().log(
           authenticationFailedMsg(authInfo.username, req)
@@ -161,10 +161,10 @@
       return false;
     }
 
-    Account account = who.get().getAccount();
+    Account account = who.get().account();
     AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
-    authRequest.setEmailAddress(account.getPreferredEmail());
-    authRequest.setDisplayName(account.getFullName());
+    authRequest.setEmailAddress(account.preferredEmail());
+    authRequest.setDisplayName(account.fullName());
     authRequest.setPassword(authInfo.tokenOrSecret);
     authRequest.setAuthPlugin(authInfo.pluginName);
     authRequest.setAuthProvider(authInfo.exportName);
diff --git a/java/com/google/gerrit/httpd/RequestMetrics.java b/java/com/google/gerrit/httpd/RequestMetrics.java
index cab4a92..e0f9b6a 100644
--- a/java/com/google/gerrit/httpd/RequestMetrics.java
+++ b/java/com/google/gerrit/httpd/RequestMetrics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -28,15 +29,20 @@
 
   @Inject
   public RequestMetrics(MetricMaker metricMaker) {
+    Field<Integer> statusCodeField =
+        Field.ofInteger("status", Metadata.Builder::httpStatus)
+            .description("HTTP status code")
+            .build();
+
     errors =
         metricMaker.newCounter(
             "http/server/error_count",
             new Description("Rate of REST API error responses").setRate().setUnit("errors"),
-            Field.ofInteger("status", "HTTP status code"));
+            statusCodeField);
     successes =
         metricMaker.newCounter(
             "http/server/success_count",
             new Description("Rate of REST API success responses").setRate().setUnit("successes"),
-            Field.ofInteger("status", "HTTP status code"));
+            statusCodeField);
   }
 }
diff --git a/java/com/google/gerrit/httpd/RunAsFilter.java b/java/com/google/gerrit/httpd/RunAsFilter.java
index 15dbcab..135de42 100644
--- a/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -19,10 +19,10 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.config.AuthConfig;
@@ -105,7 +105,7 @@
 
       Account.Id target;
       try {
-        target = accountResolver.resolve(runas).asUnique().getAccount().getId();
+        target = accountResolver.resolve(runas).asUnique().account().id();
       } catch (UnprocessableEntityException e) {
         replyError(req, res, SC_FORBIDDEN, "no account matches " + RUN_AS, null);
         return;
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 993a042..ac73d22 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -18,7 +18,10 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.httpd.raw.AuthorizationCheckServlet;
 import com.google.gerrit.httpd.raw.CatServlet;
 import com.google.gerrit.httpd.raw.SshInfoServlet;
 import com.google.gerrit.httpd.raw.ToolServlet;
@@ -28,8 +31,6 @@
 import com.google.gerrit.httpd.restapi.ConfigRestApiServlet;
 import com.google.gerrit.httpd.restapi.GroupsRestApiServlet;
 import com.google.gerrit.httpd.restapi.ProjectsRestApiServlet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Key;
 import com.google.inject.internal.UniqueAnnotations;
@@ -82,6 +83,9 @@
 
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
 
+    // Serve auth check. Mainly used by PolyGerrit for checking if a user is still logged in.
+    serveRegex("^/(?:a/)?auth-check$").with(AuthorizationCheckServlet.class);
+
     // Bind servlets for REST root collections.
     // The '/plugins/' root collection is already handled by HttpPluginServlet
     // which is bound in HttpPluginModule. We cannot bind it here again although
@@ -147,7 +151,7 @@
             while (name.endsWith("/")) {
               name = name.substring(0, name.length() - 1);
             }
-            Project.NameKey project = new Project.NameKey(name);
+            Project.NameKey project = Project.nameKey(name);
             toGerrit(
                 PageLinks.toChangeQuery(PageLinks.projectQuery(project, Change.Status.NEW)),
                 req,
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e476f15..e8b54fe 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.httpd;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index d09b4dd..c0900ec 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -286,7 +286,7 @@
           case 0:
             break PARSE;
           case 1:
-            accountId = new Account.Id(readVarInt32(in));
+            accountId = Account.id(readVarInt32(in));
             continue;
           case 2:
             refreshCookieAt = readFixInt64(in);
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 552e667..97bb44b 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -18,12 +18,12 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -153,20 +153,20 @@
       if (!accountState.isPresent()) {
         continue;
       }
-      Account account = accountState.get().getAccount();
+      Account account = accountState.get().account();
       String displayName;
-      if (accountState.get().getUserName().isPresent()) {
-        displayName = accountState.get().getUserName().get();
-      } else if (account.getFullName() != null && !account.getFullName().isEmpty()) {
-        displayName = account.getFullName();
-      } else if (account.getPreferredEmail() != null) {
-        displayName = account.getPreferredEmail();
+      if (accountState.get().userName().isPresent()) {
+        displayName = accountState.get().userName().get();
+      } else if (account.fullName() != null && !account.fullName().isEmpty()) {
+        displayName = account.fullName();
+      } else if (account.preferredEmail() != null) {
+        displayName = account.preferredEmail();
       } else {
         displayName = accountId.toString();
       }
 
       Element linkElement = doc.createElement("a");
-      linkElement.setAttribute("href", "?account_id=" + account.getId().toString());
+      linkElement.setAttribute("href", "?account_id=" + account.id().toString());
       linkElement.setTextContent(displayName);
       userlistElement.appendChild(linkElement);
       userlistElement.appendChild(doc.createElement("br"));
@@ -176,7 +176,7 @@
   }
 
   private Optional<AuthResult> auth(Optional<AccountState> account) {
-    return account.map(a -> new AuthResult(a.getAccount().getId(), null, false));
+    return account.map(a -> new AuthResult(a.account().id(), null, false));
   }
 
   private AuthResult auth(Account.Id account) {
@@ -196,7 +196,7 @@
       getServletContext().log("Multiple accounts with username " + userName + " found");
       return null;
     }
-    return auth(accountStates.get(0).getAccount().getId());
+    return auth(accountStates.get(0).account().id());
   }
 
   private Optional<AuthResult> byPreferredEmail(String email) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index e201e59..dd4549e 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -8,18 +8,17 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:gson",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 0c8a1a10..fcaef5e 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -19,6 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -27,7 +28,6 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.CanonicalWebUrl;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -237,7 +237,7 @@
     try {
       return SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new IllegalArgumentException("No SecureRandom available for GitHub authentication", e);
+      throw new IllegalStateException("No SecureRandom available for GitHub authentication", e);
     }
   }
 
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index cd204e7..94f436b 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -10,20 +10,18 @@
         # We want all these deps to be provided_deps
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/util/http",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//java/com/google/gwtorm",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/openid:consumer",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index 08f2d52..37250b4 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -27,7 +28,6 @@
 import com.google.gerrit.httpd.CanonicalWebUrl;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -222,7 +222,7 @@
     try {
       return SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new IllegalArgumentException("No SecureRandom available for GitHub authentication", e);
+      throw new IllegalStateException("No SecureRandom available for GitHub authentication", e);
     }
   }
 
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index 8c3dc10..be975c5 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -17,12 +17,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.KeyUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.CanonicalWebUrl;
 import com.google.gerrit.httpd.ProxyProperties;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index b438c00..4fabb18 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -36,10 +36,10 @@
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -174,10 +174,8 @@
     }
     Path myconf = Files.createTempFile(site.tmp_dir, "gitweb_config", ".perl");
 
-    // To make our configuration file only readable or writable by us;
-    // this reduces the chances of someone tampering with the file.
-    //
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
+    // To make our configuration file only readable or writable by us; this reduces the chances of
+    // someone tampering with the file.
     File myconfFile = myconf.toFile();
     myconfFile.setWritable(false, false /* all */);
     myconfFile.setReadable(false, false /* all */);
@@ -414,7 +412,7 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    Project.NameKey nameKey = new Project.NameKey(name);
+    Project.NameKey nameKey = Project.nameKey(name);
     ProjectState projectState;
     try {
       projectState = projectCache.checkedGet(nameKey);
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index de94a1a..222041a 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
         "//java/com/google/gerrit/metrics/dropwizard",
@@ -26,10 +27,10 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/sshd",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index bf5cd2a..0befbd3 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -38,7 +38,9 @@
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
@@ -72,9 +74,9 @@
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.OnlineUpgrader;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
@@ -261,6 +263,13 @@
 
     Module configModule = new GerritServerConfigModule();
     modules.add(configModule);
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            listener().to(SystemReaderInstaller.class);
+          }
+        });
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
@@ -342,13 +351,12 @@
   }
 
   private Module createIndexModule() {
-    switch (indexType) {
-      case LUCENE:
-        return LuceneIndexModule.latestVersion(false);
-      case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersion(false);
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (indexType.isLucene()) {
+      return LuceneIndexModule.latestVersion(false);
+    } else if (indexType.isElasticsearch()) {
+      return ElasticIndexModule.latestVersion(false);
+    } else {
+      throw new IllegalStateException("unsupported index.type = " + indexType);
     }
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
new file mode 100644
index 0000000..e8f173c
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.util.http.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Offers a dedicated endpoint for checking if a user is still logged in. Returns {@code 204
+ * NO_CONTENT} for logged-in users, {@code 403 FORBIDDEN} otherwise.
+ *
+ * <p>Mainly used by PolyGerrit to check if a user is still logged in.
+ */
+@Singleton
+public class AuthorizationCheckServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+  private final Provider<CurrentUser> user;
+
+  @Inject
+  AuthorizationCheckServlet(Provider<CurrentUser> user) {
+    this.user = user;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    CacheHeaders.setNotCacheable(res);
+    if (user.get().isIdentifiedUser()) {
+      res.setStatus(HttpServletResponse.SC_NO_CONTENT);
+    } else {
+      res.setStatus(HttpServletResponse.SC_FORBIDDEN);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 1d0e7d8b..a295213 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.httpd.raw;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -118,13 +118,13 @@
       }
     }
 
-    final Change.Id changeId = patchKey.getParentKey().getParentKey();
+    final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
       ChangeNotes notes = changeNotesFactory.createChecked(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache.checkedGet(notes.getProjectName()).checkStatePermitsRead();
-      if (patchKey.getParentKey().get() == 0) {
+      if (patchKey.patchSetId().get() == 0) {
         // change edit
         Optional<ChangeEdit> edit = changeEditUtil.byChange(notes);
         if (edit.isPresent()) {
@@ -134,12 +134,12 @@
           return;
         }
       } else {
-        PatchSet patchSet = psUtil.get(notes, patchKey.getParentKey());
+        PatchSet patchSet = psUtil.get(notes, patchKey.patchSetId());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
         }
-        revision = patchSet.getRevision().get();
+        revision = patchSet.commitId().name();
       }
     } catch (ResourceConflictException | NoSuchChangeException | AuthException e) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -150,7 +150,7 @@
       return;
     }
 
-    String path = patchKey.getFileName();
+    String path = patchKey.fileName();
     String restUrl =
         String.format(
             "%s/changes/%d/revisions/%s/files/%s/download?parent=%d",
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
new file mode 100644
index 0000000..b9b66bc
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -0,0 +1,149 @@
+// 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.httpd.raw;
+
+import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gson.Gson;
+import com.google.template.soy.data.SanitizedContent;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Helper for generating parts of {@code index.html}. */
+public class IndexHtmlUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /**
+   * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
+   * rendering the soy template.
+   */
+  public static ImmutableMap<String, Object> templateData(
+      GerritApi gerritApi,
+      String canonicalURL,
+      String cdnPath,
+      String faviconPath,
+      Map<String, String[]> urlParameterMap,
+      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      throws URISyntaxException, RestApiException {
+    return ImmutableMap.<String, Object>builder()
+        .putAll(
+            staticTemplateData(
+                canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
+        .putAll(dynamicTemplateData(gerritApi))
+        .build();
+  }
+
+  /** Returns dynamic parameters of {@code index.html}. */
+  @UsedAt(Project.GOOGLE)
+  public static Map<String, Map<String, SanitizedContent>> dynamicTemplateData(GerritApi gerritApi)
+      throws RestApiException {
+    Gson gson = OutputFormat.JSON_COMPACT.newGson();
+    Map<String, SanitizedContent> initialData = new HashMap<>();
+    Server serverApi = gerritApi.config().server();
+    initialData.put("\"/config/server/info\"", serializeObject(gson, serverApi.getInfo()));
+    initialData.put("\"/config/server/version\"", serializeObject(gson, serverApi.getVersion()));
+    initialData.put("\"/config/server/top-menus\"", serializeObject(gson, serverApi.topMenus()));
+
+    try {
+      AccountApi accountApi = gerritApi.accounts().self();
+      initialData.put("\"/accounts/self/detail\"", serializeObject(gson, accountApi.get()));
+      initialData.put(
+          "\"/accounts/self/preferences\"", serializeObject(gson, accountApi.getPreferences()));
+      initialData.put(
+          "\"/accounts/self/preferences.diff\"",
+          serializeObject(gson, accountApi.getDiffPreferences()));
+      initialData.put(
+          "\"/accounts/self/preferences.edit\"",
+          serializeObject(gson, accountApi.getEditPreferences()));
+    } catch (AuthException e) {
+      logger.atFine().withCause(e).log(
+          "Can't inline account-related data because user is unauthenticated");
+      // Don't render data
+      // TODO(hiesel): Tell the client that the user is not authenticated so that it doesn't have to
+      // fetch anyway. This requires more client side modifications.
+    }
+    return ImmutableMap.of("gerritInitialData", initialData);
+  }
+
+  /** Returns all static parameters of {@code index.html}. */
+  static Map<String, Object> staticTemplateData(
+      String canonicalURL,
+      String cdnPath,
+      String faviconPath,
+      Map<String, String[]> urlParameterMap,
+      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      throws URISyntaxException {
+    String canonicalPath = computeCanonicalPath(canonicalURL);
+
+    String staticPath = "";
+    if (cdnPath != null) {
+      staticPath = cdnPath;
+    } else if (canonicalPath != null) {
+      staticPath = canonicalPath;
+    }
+
+    SanitizedContent sanitizedStaticPath = urlInScriptTagOrdainer.apply(staticPath);
+    ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
+
+    if (canonicalPath != null) {
+      data.put("canonicalPath", canonicalPath);
+    }
+    if (sanitizedStaticPath != null) {
+      data.put("staticResourcePath", sanitizedStaticPath);
+    }
+    if (faviconPath != null) {
+      data.put("faviconPath", faviconPath);
+    }
+    if (urlParameterMap.containsKey("ce")) {
+      data.put("polyfillCE", "true");
+    }
+    if (urlParameterMap.containsKey("sd")) {
+      data.put("polyfillSD", "true");
+    }
+    if (urlParameterMap.containsKey("sc")) {
+      data.put("polyfillSC", "true");
+    }
+    return data.build();
+  }
+
+  private static String computeCanonicalPath(@Nullable String canonicalURL)
+      throws URISyntaxException {
+    if (Strings.isNullOrEmpty(canonicalURL)) {
+      return "";
+    }
+
+    // If we serving from a sub-directory rather than root, determine the path
+    // from the cannonical web URL.
+    URI uri = new URI(canonicalURL);
+    return uri.getPath().replaceAll("/$", "");
+  }
+
+  private IndexHtmlUtil() {}
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index b6594bc..a0b41b21 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -17,91 +17,73 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.Resources;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.Map;
+import java.util.function.Function;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  protected final byte[] indexSource;
+
+  @Nullable private final String canonicalUrl;
+  @Nullable private final String cdnPath;
+  @Nullable private final String faviconPath;
+  private final GerritApi gerritApi;
+  private final SoySauce soySauce;
+  private final Function<String, SanitizedContent> urlOrdainer;
 
   IndexServlet(
-      @Nullable String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
-      throws URISyntaxException {
-    String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.add(Resources.getResource(resourcePath));
-    SoyTofu.Renderer renderer =
-        builder
+      @Nullable String canonicalUrl,
+      @Nullable String cdnPath,
+      @Nullable String faviconPath,
+      GerritApi gerritApi) {
+    this.canonicalUrl = canonicalUrl;
+    this.cdnPath = cdnPath;
+    this.faviconPath = faviconPath;
+    this.gerritApi = gerritApi;
+    this.soySauce =
+        SoyFileSet.builder()
+            .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
             .build()
-            .compileToTofu()
-            .newRenderer("com.google.gerrit.httpd.raw.Index")
-            .setContentKind(SanitizedContent.ContentKind.HTML)
-            .setData(getTemplateData(canonicalURL, cdnPath, faviconPath));
-    indexSource = renderer.render().getBytes(UTF_8);
+            .compileTemplates();
+    this.urlOrdainer =
+        (s) ->
+            UnsafeSanitizedContentOrdainer.ordainAsSafe(
+                s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
   }
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    SoySauce.Renderer renderer;
+    try {
+      Map<String, String[]> parameterMap = req.getParameterMap();
+      // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
+      ImmutableMap<String, Object> templateData =
+          IndexHtmlUtil.templateData(
+              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer);
+      renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
+    } catch (URISyntaxException | RestApiException e) {
+      throw new IOException(e);
+    }
+
     rsp.setCharacterEncoding(UTF_8.name());
     rsp.setContentType("text/html");
     rsp.setStatus(SC_OK);
     try (OutputStream w = rsp.getOutputStream()) {
-      w.write(indexSource);
+      w.write(renderer.renderHtml().get().toString().getBytes(UTF_8));
     }
   }
-
-  static String computeCanonicalPath(@Nullable String canonicalURL) throws URISyntaxException {
-    if (Strings.isNullOrEmpty(canonicalURL)) {
-      return "";
-    }
-
-    // If we serving from a sub-directory rather than root, determine the path
-    // from the cannonical web URL.
-    URI uri = new URI(canonicalURL);
-    return uri.getPath().replaceAll("/$", "");
-  }
-
-  static Map<String, Object> getTemplateData(
-      String canonicalURL, String cdnPath, String faviconPath) throws URISyntaxException {
-    String canonicalPath = computeCanonicalPath(canonicalURL);
-
-    String staticPath = "";
-    if (cdnPath != null) {
-      staticPath = cdnPath;
-    } else if (canonicalPath != null) {
-      staticPath = canonicalPath;
-    }
-
-    // The resource path must be typed as safe for use in a script src.
-    // TODO(wyatta): Upgrade this to use an appropriate safe URL type.
-    SanitizedContent sanitizedStaticPath =
-        UnsafeSanitizedContentOrdainer.ordainAsSafe(
-            staticPath, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
-
-    ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
-    if (canonicalPath != null) {
-      data.put("canonicalPath", canonicalPath);
-    }
-    if (sanitizedStaticPath != null) {
-      data.put("staticResourcePath", sanitizedStaticPath);
-    }
-    if (faviconPath != null) {
-      data.put("faviconPath", faviconPath);
-    }
-    return data.build();
-  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 06ac886..0d4c67e 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.launcher.GerritLauncher;
@@ -41,7 +42,6 @@
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.net.URISyntaxException;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import javax.servlet.Filter;
@@ -78,11 +78,6 @@
           "/groups/self",
           "/settings/*",
           "/Documentation/q/*");
-  // TODO(dborowitz): These fragments conflict with the REST API
-  // namespace, so they will need to use a different path.
-  // "/groups/*",
-  // "/projects/*");
-  //
 
   /**
    * Paths that should be treated as static assets when serving PolyGerrit.
@@ -224,11 +219,12 @@
     @Singleton
     @Named(POLYGERRIT_INDEX_SERVLET)
     HttpServlet getPolyGerritUiIndexServlet(
-        @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
-        throws URISyntaxException {
+        @CanonicalWebUrl @Nullable String canonicalUrl,
+        @GerritServerConfig Config cfg,
+        GerritApi gerritApi) {
       String cdnPath = cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
index 562687b..fc099a6 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -41,19 +42,24 @@
 
   @Inject
   RestApiMetrics(MetricMaker metrics) {
-    Field<String> view = Field.ofString("view", "view implementation class");
+    Field<String> viewField =
+        Field.ofString("view", Metadata.Builder::className)
+            .description("view implementation class")
+            .build();
     count =
         metrics.newCounter(
             "http/server/rest_api/count",
             new Description("REST API calls by view").setRate(),
-            view);
+            viewField);
 
     errorCount =
         metrics.newCounter(
             "http/server/rest_api/error_count",
             new Description("REST API errors by view").setRate(),
-            view,
-            Field.ofInteger("error_code", "HTTP status code"));
+            viewField,
+            Field.ofInteger("error_code", Metadata.Builder::httpStatus)
+                .description("HTTP status code")
+                .build());
 
     serverLatency =
         metrics.newTimer(
@@ -61,7 +67,7 @@
             new Description("REST API call latency by view")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            viewField);
 
     responseBytes =
         metrics.newHistogram(
@@ -69,7 +75,7 @@
             new Description("Size of response on network (may be gzip compressed)")
                 .setCumulative()
                 .setUnit(Units.BYTES),
-            view);
+            viewField);
   }
 
   String view(ViewData viewData) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 4d04e0a..83a2179 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -34,17 +34,14 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
-import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
-import static javax.servlet.http.HttpServletResponse.SC_CREATED;
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
-import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
@@ -67,8 +64,10 @@
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -105,16 +104,25 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.util.http.CacheHeaders;
@@ -158,6 +166,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
@@ -186,9 +195,6 @@
 
   @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
 
-  // HTTP 422 Unprocessable Entity.
-  // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
-  private static final int SC_UNPROCESSABLE_ENTITY = 422;
   private static final String X_REQUESTED_WITH = "X-Requested-With";
   private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
   static final ImmutableSet<String> ALLOWED_CORS_METHODS =
@@ -201,6 +207,8 @@
   public static final String XD_AUTHORIZATION = "access_token";
   public static final String XD_CONTENT_TYPE = "$ct";
   public static final String XD_METHOD = "$m";
+  public static final int SC_UNPROCESSABLE_ENTITY = 422;
+  public static final int SC_TOO_MANY_REQUESTS = 429;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -224,30 +232,41 @@
     final Provider<CurrentUser> currentUser;
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
+    final PluginSetContext<RequestListener> requestListeners;
     final PermissionBackend permissionBackend;
     final GroupAuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
     final RestApiQuotaEnforcer quotaChecker;
+    final Config config;
+    final DynamicSet<PerformanceLogger> performanceLoggers;
+    final ChangeFinder changeFinder;
 
     @Inject
     Globals(
         Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
+        PluginSetContext<RequestListener> requestListeners,
         PermissionBackend permissionBackend,
         GroupAuditService auditService,
         RestApiMetrics metrics,
         RestApiQuotaEnforcer quotaChecker,
-        @GerritServerConfig Config cfg) {
+        @GerritServerConfig Config config,
+        DynamicSet<PerformanceLogger> performanceLoggers,
+        ChangeFinder changeFinder) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
+      this.requestListeners = requestListeners;
       this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
       this.quotaChecker = quotaChecker;
-      allowOrigin = makeAllowOrigin(cfg);
+      this.config = config;
+      this.performanceLoggers = performanceLoggers;
+      this.changeFinder = changeFinder;
+      allowOrigin = makeAllowOrigin(config);
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -261,6 +280,7 @@
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
+  private Optional<String> traceId = Optional.empty();
 
   public RestApiServlet(
       Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
@@ -286,272 +306,288 @@
     res.setHeader("X-Content-Type-Options", "nosniff");
     int status = SC_OK;
     long responseBytes = -1;
-    Object result = null;
+    Response<?> response = null;
     QueryParams qp = null;
     Object inputRequestBody = null;
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
     try (TraceContext traceContext = enableTracing(req, res)) {
+      List<IdString> path = splitPath(req);
+
+      RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
+      globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
+
       try (PerThreadCache ignored = PerThreadCache.create()) {
-        logger.atFinest().log(
-            "Received REST request: %s %s (parameters: %s)",
-            req.getMethod(), req.getRequestURI(), getParameterNames(req));
-        logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
+        // It's important that the PerformanceLogContext is closed before the response is sent to
+        // the client. Only this way it is ensured that the invocation of the PerformanceLogger
+        // plugins happens before the client sees the response. This is needed for being able to
+        // test performance logging from an acceptance test (see
+        // TraceIT#performanceLoggingForRestCall()).
+        try (PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
+          logger.atFinest().log(
+              "Received REST request: %s %s (parameters: %s)",
+              req.getMethod(), req.getRequestURI(), getParameterNames(req));
+          logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
+          logger.atFinest().log(
+              "Groups: %s",
+              lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
 
-        if (isCorsPreflight(req)) {
-          doCorsPreflight(req, res);
-          return;
-        }
-
-        qp = ParameterParser.getQueryParams(req);
-        checkCors(req, res, qp.hasXdOverride());
-        if (qp.hasXdOverride()) {
-          req = applyXdOverrides(req, qp);
-        }
-        checkUserSession(req);
-
-        List<IdString> path = splitPath(req);
-        RestCollection<RestResource, RestResource> rc = members.get();
-        globals
-            .permissionBackend
-            .currentUser()
-            .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-        viewData = new ViewData(null, null);
-
-        if (path.isEmpty()) {
-          globals.quotaChecker.enforce(req);
-          if (rc instanceof NeedsParams) {
-            ((NeedsParams) rc).setParams(qp.params());
+          if (isCorsPreflight(req)) {
+            doCorsPreflight(req, res);
+            return;
           }
 
-          if (isRead(req)) {
-            viewData = new ViewData(null, rc.list());
-          } else if (isPost(req)) {
-            RestView<RestResource> restCollectionView =
-                rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
-            if (restCollectionView != null) {
-              viewData = new ViewData(null, restCollectionView);
-            } else {
-              throw methodNotAllowed(req);
-            }
-          } else {
-            // DELETE on root collections is not supported
-            throw methodNotAllowed(req);
+          qp = ParameterParser.getQueryParams(req);
+          checkCors(req, res, qp.hasXdOverride());
+          if (qp.hasXdOverride()) {
+            req = applyXdOverrides(req, qp);
           }
-        } else {
-          IdString id = path.remove(0);
-          try {
-            rsrc = rc.parse(rsrc, id);
-            globals.quotaChecker.enforce(rsrc, req);
-            if (path.isEmpty()) {
-              checkPreconditions(req);
-            }
-          } catch (ResourceNotFoundException e) {
-            if (!path.isEmpty()) {
-              throw e;
-            }
-            globals.quotaChecker.enforce(req);
+          checkUserSession(req);
 
-            if (isPost(req) || isPut(req)) {
-              RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
-              if (createView != null) {
-                viewData = new ViewData(null, createView);
-                status = SC_CREATED;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else if (isDelete(req)) {
-              RestView<RestResource> deleteView =
-                  rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
-              if (deleteView != null) {
-                viewData = new ViewData(null, deleteView);
-                status = SC_NO_CONTENT;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else {
-              throw e;
-            }
-          }
-          if (viewData.view == null) {
-            viewData = view(rc, req.getMethod(), path);
-          }
-        }
-        checkRequiresCapability(viewData);
+          RestCollection<RestResource, RestResource> rc = members.get();
+          globals
+              .permissionBackend
+              .currentUser()
+              .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
-        while (viewData.view instanceof RestCollection<?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollection<RestResource, RestResource> c =
-              (RestCollection<RestResource, RestResource>) viewData.view;
+          viewData = new ViewData(null, null);
 
           if (path.isEmpty()) {
+            globals.quotaChecker.enforce(req);
+            if (rc instanceof NeedsParams) {
+              ((NeedsParams) rc).setParams(qp.params());
+            }
+
             if (isRead(req)) {
-              viewData = new ViewData(null, c.list());
+              viewData = new ViewData(null, rc.list());
             } else if (isPost(req)) {
-              // TODO: Here and on other collection methods: There is a bug that binds child views
-              // with pluginName="gerrit" instead of the real plugin name. This has never worked
-              // correctly and should be fixed where the binding gets created (DynamicMapProvider)
-              // and here.
               RestView<RestResource> restCollectionView =
-                  c.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
-              if (restCollectionView != null) {
-                viewData = new ViewData(null, restCollectionView);
-              } else {
-                throw methodNotAllowed(req);
-              }
-            } else if (isDelete(req)) {
-              RestView<RestResource> restCollectionView =
-                  c.views().get(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
+                  rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
               if (restCollectionView != null) {
                 viewData = new ViewData(null, restCollectionView);
               } else {
                 throw methodNotAllowed(req);
               }
             } else {
+              // DELETE on root collections is not supported
               throw methodNotAllowed(req);
             }
-            break;
-          }
-          IdString id = path.remove(0);
-          try {
-            rsrc = c.parse(rsrc, id);
-            checkPreconditions(req);
-            viewData = new ViewData(null, null);
-          } catch (ResourceNotFoundException e) {
-            if (!path.isEmpty()) {
-              throw e;
-            }
+          } else {
+            IdString id = path.remove(0);
+            try {
+              rsrc = rc.parse(rsrc, id);
+              globals.quotaChecker.enforce(rsrc, req);
+              if (path.isEmpty()) {
+                checkPreconditions(req);
+              }
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
+              globals.quotaChecker.enforce(req);
 
-            if (isPost(req) || isPut(req)) {
-              RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
-              if (createView != null) {
-                viewData = new ViewData(viewData.pluginName, createView);
-                status = SC_CREATED;
-                path.add(id);
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(null, createView);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> deleteView =
+                    rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+                if (deleteView != null) {
+                  viewData = new ViewData(null, deleteView);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
               } else {
                 throw e;
               }
-            } else if (isDelete(req)) {
-              RestView<RestResource> deleteView =
-                  c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
-              if (deleteView != null) {
-                viewData = new ViewData(viewData.pluginName, deleteView);
-                status = SC_NO_CONTENT;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else {
-              throw e;
             }
-          }
-          if (viewData.view == null) {
-            viewData = view(c, req.getMethod(), path);
+            if (viewData.view == null) {
+              viewData = view(rc, req.getMethod(), path);
+            }
           }
           checkRequiresCapability(viewData);
-        }
 
-        if (notModified(req, rsrc, viewData.view)) {
-          logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
-          res.sendError(SC_NOT_MODIFIED);
-          return;
-        }
+          while (viewData.view instanceof RestCollection<?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollection<RestResource, RestResource> c =
+                (RestCollection<RestResource, RestResource>) viewData.view;
 
-        if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-          return;
-        }
-
-        if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-          result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-        } else if (viewData.view instanceof RestModifyView<?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestModifyView<RestResource, Object> m =
-              (RestModifyView<RestResource, Object>) viewData.view;
-
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+            if (path.isEmpty()) {
+              if (isRead(req)) {
+                viewData = new ViewData(null, c.list());
+              } else if (isPost(req)) {
+                // TODO: Here and on other collection methods: There is a bug that binds child views
+                // with pluginName="gerrit" instead of the real plugin name. This has never worked
+                // correctly and should be fixed where the binding gets created (DynamicMapProvider)
+                // and here.
+                RestView<RestResource> restCollectionView =
+                    c.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+                if (restCollectionView != null) {
+                  viewData = new ViewData(null, restCollectionView);
+                } else {
+                  throw methodNotAllowed(req);
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> restCollectionView =
+                    c.views().get(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
+                if (restCollectionView != null) {
+                  viewData = new ViewData(null, restCollectionView);
+                } else {
+                  throw methodNotAllowed(req);
+                }
+              } else {
+                throw methodNotAllowed(req);
+              }
+              break;
             }
-          }
-        } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionCreateView<RestResource, RestResource, Object> m =
-              (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+            IdString id = path.remove(0);
+            try {
+              rsrc = c.parse(rsrc, id);
+              checkPreconditions(req);
+              viewData = new ViewData(null, null);
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
 
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, path.get(0), inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(viewData.pluginName, createView);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> deleteView =
+                    c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+                if (deleteView != null) {
+                  viewData = new ViewData(viewData.pluginName, deleteView);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else {
+                throw e;
+              }
             }
-          }
-        } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-              (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, path.get(0), inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+            if (viewData.view == null) {
+              viewData = view(c, req.getMethod(), path);
             }
+            checkRequiresCapability(viewData);
           }
-        } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionModifyView<RestResource, RestResource, Object> m =
-              (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+          if (notModified(req, rsrc, viewData.view)) {
+            logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
+            res.sendError(SC_NOT_MODIFIED);
+            return;
+          }
+
+          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+            return;
+          }
+
+          if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+            response = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+          } else if (viewData.view instanceof RestModifyView<?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestModifyView<RestResource, Object> m =
+                (RestModifyView<RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
             }
-          }
-        } else {
-          throw new ResourceNotFoundException();
-        }
+          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionCreateView<RestResource, RestResource, Object> m =
+                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
-        if (result instanceof Response) {
-          @SuppressWarnings("rawtypes")
-          Response<?> r = (Response) result;
-          status = r.statusCode();
-          configureCaching(req, res, rsrc, viewData.view, r.caching());
-        } else if (result instanceof Response.Redirect) {
-          CacheHeaders.setNotCacheable(res);
-          String location = ((Response.Redirect) result).location();
-          res.sendRedirect(location);
-          logger.atFinest().log("REST call redirected to: %s", location);
-          return;
-        } else if (result instanceof Response.Accepted) {
-          CacheHeaders.setNotCacheable(res);
-          res.setStatus(SC_ACCEPTED);
-          res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-          logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
-          return;
-        } else {
-          CacheHeaders.setNotCacheable(res);
-        }
-        res.setStatus(status);
-        logger.atFinest().log("REST call succeeded: %d", status);
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = m.apply(rsrc, path.get(0), inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
 
-        if (result != Response.none()) {
-          result = Response.unwrap(result);
-          if (result instanceof BinaryResult) {
-            responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = m.apply(rsrc, path.get(0), inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionModifyView<RestResource, RestResource, Object> m =
+                (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
           } else {
-            responseBytes = replyJson(req, res, false, qp.config(), result);
+            throw new ResourceNotFoundException();
+          }
+
+          traceId = response.traceId();
+          traceId.ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+
+          if (response instanceof Response.Redirect) {
+            CacheHeaders.setNotCacheable(res);
+            String location = ((Response.Redirect) response).location();
+            res.sendRedirect(location);
+            logger.atFinest().log("REST call redirected to: %s", location);
+            return;
+          } else if (response instanceof Response.Accepted) {
+            CacheHeaders.setNotCacheable(res);
+            res.setStatus(response.statusCode());
+            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+            logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+            return;
+          } else if (response instanceof Response.InternalServerError) {
+            // Rethrow the exception to have exactly the same error handling as if the REST endpoint
+            // would have thrown the exception directly, instead of returning
+            // Response.InternalServerError.
+            Exception cause = ((Response.InternalServerError<?>) response).cause();
+            throw cause;
+          }
+
+          status = response.statusCode();
+          configureCaching(req, res, rsrc, viewData.view, response.caching());
+          res.setStatus(status);
+          logger.atFinest().log("REST call succeeded: %d", status);
+        }
+
+        if (response != Response.none()) {
+          Object value = Response.unwrap(response);
+          if (value instanceof BinaryResult) {
+            responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+          } else {
+            responseBytes = replyJson(req, res, false, qp.config(), value);
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
@@ -601,21 +637,27 @@
                 e.caching(),
                 e);
       } catch (NotImplementedException e) {
+        logger.atSevere().withCause(e).log("Error in %s %s", req.getMethod(), uriForLogging(req));
         responseBytes =
             replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
       } catch (UpdateException e) {
         Throwable t = e.getCause();
         if (t instanceof LockFailureException) {
-          responseBytes =
-              replyError(
-                  req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
+          logger.atSevere().withCause(t).log("Error in %s %s", req.getMethod(), uriForLogging(req));
+          responseBytes = replyError(req, res, status = SC_SERVICE_UNAVAILABLE, "Lock failure", e);
         } else {
           status = SC_INTERNAL_SERVER_ERROR;
           responseBytes = handleException(e, req, res);
         }
       } catch (QuotaException e) {
         responseBytes =
-            replyError(req, res, status = 429, messageOr(e, "Quota limit reached"), e.caching(), e);
+            replyError(
+                req,
+                res,
+                status = SC_TOO_MANY_REQUESTS,
+                messageOr(e, "Quota limit reached"),
+                e.caching(),
+                e);
       } catch (Exception e) {
         status = SC_INTERNAL_SERVER_ERROR;
         responseBytes = handleException(e, req, res);
@@ -640,7 +682,7 @@
                 qp != null ? qp.params() : ImmutableListMultimap.of(),
                 inputRequestBody,
                 status,
-                result,
+                response,
                 rsrc,
                 viewData == null ? null : viewData.view));
       }
@@ -1387,6 +1429,29 @@
     return traceContext;
   }
 
+  private RequestInfo createRequestInfo(
+      TraceContext traceContext, String requestUri, List<IdString> path) {
+    RequestInfo.Builder requestInfo =
+        RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext)
+            .requestUri(requestUri);
+
+    if (path.size() < 1) {
+      return requestInfo.build();
+    }
+
+    RestCollection<?, ?> rootCollection = members.get();
+    String resourceId = path.get(0).get();
+    if (rootCollection instanceof ProjectsCollection) {
+      requestInfo.project(Project.nameKey(resourceId));
+    } else if (rootCollection instanceof ChangesCollection) {
+      ChangeNotes changeNotes = globals.changeFinder.findOne(resourceId);
+      if (changeNotes != null) {
+        requestInfo.project(changeNotes.getProjectName());
+      }
+    }
+    return requestInfo.build();
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1429,18 +1494,23 @@
     }
   }
 
-  private static long handleException(
-      Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
+  private long handleException(Throwable err, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uriForLogging(req));
+    if (!res.isCommitted()) {
+      res.reset();
+      traceId.ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
+    }
+    return 0;
+  }
+
+  private static String uriForLogging(HttpServletRequest req) {
     String uri = req.getRequestURI();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
     }
-    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uri);
-    if (!res.isCommitted()) {
-      res.reset();
-      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
-    }
-    return 0;
+    return uri;
   }
 
   public static long replyError(
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 55c7746..95b3581 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -22,17 +22,18 @@
         ":query_exception",
         "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index b5b36f1..29b8ea6 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.auto.value.AutoValue;
+import java.util.function.Consumer;
 import java.util.function.IntConsumer;
 import org.eclipse.jgit.lib.Config;
 
@@ -39,6 +40,7 @@
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
+    setTypeOrDefault(cfg, b::type);
     return b;
   }
 
@@ -49,11 +51,17 @@
     }
   }
 
+  private static void setTypeOrDefault(Config cfg, Consumer<String> setter) {
+    String type = cfg != null ? cfg.getString("index", null, "type") : null;
+    setter.accept(new IndexType(type).toString());
+  }
+
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
+        .type(IndexType.getDefault())
         .separateChangeSubIndexes(false);
   }
 
@@ -71,6 +79,10 @@
 
     public abstract int maxTerms();
 
+    public abstract Builder type(String type);
+
+    public abstract String type();
+
     public abstract Builder separateChangeSubIndexes(boolean separate);
 
     abstract IndexConfig autoBuild();
@@ -105,6 +117,9 @@
    */
   public abstract int maxTerms();
 
+  /** @return index type. */
+  public abstract String type();
+
   /**
    * @return whether different subsets of changes may be stored in different physical sub-indexes.
    */
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
new file mode 100644
index 0000000..ee44deb
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * Index types supported by the secondary index.
+ *
+ * <p>The explicitly known index types are Lucene (the default) and Elasticsearch.
+ *
+ * <p>The third supported index type is any other type String value, deemed as custom. This is for
+ * configuring index types that are internal or not to be disclosed. Supporting custom index types
+ * allows to not break that case upon core implementation changes.
+ */
+public class IndexType {
+  private static final String LUCENE = "lucene";
+  private static final String ELASTICSEARCH = "elasticsearch";
+
+  private final String type;
+
+  public IndexType(@Nullable String type) {
+    this.type = type == null ? getDefault() : type.toLowerCase();
+  }
+
+  public static String getDefault() {
+    return LUCENE;
+  }
+
+  public static ImmutableSet<String> getKnownTypes() {
+    return ImmutableSet.of(LUCENE, ELASTICSEARCH);
+  }
+
+  public boolean isLucene() {
+    return type.equals(LUCENE);
+  }
+
+  public boolean isElasticsearch() {
+    return type.equals(ELASTICSEARCH);
+  }
+
+  @Override
+  public String toString() {
+    return type;
+  }
+}
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
index f0e465d..956dcab 100644
--- a/java/com/google/gerrit/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -23,10 +23,10 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -42,7 +42,7 @@
       String s = new String(b, UTF_8);
       List<String> parts = Splitter.on(':').splitToList(s);
       RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
-      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
+      result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
     }
     return result;
   }
@@ -61,7 +61,7 @@
 
   public byte[] toByteArray(Project.NameKey project) {
     byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+    byte[] b = new byte[a.length + ObjectIds.STR_LEN];
     System.arraycopy(a, 0, b, 0, a.length);
     id().copyTo(b, a.length);
     return b;
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index e633bfa..f9f8c48 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -26,6 +25,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 /** Specific version of a secondary index schema. */
@@ -34,6 +34,7 @@
 
   public static class Builder<T> {
     private final List<FieldDef<T, ?>> fields = new ArrayList<>();
+    private boolean useLegacyNumericFields;
 
     public Builder<T> add(Schema<T> schema) {
       this.fields.addAll(schema.getFields().values());
@@ -52,8 +53,13 @@
       return this;
     }
 
+    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
+      this.useLegacyNumericFields = useLegacyNumericFields;
+      return this;
+    }
+
     public Schema<T> build() {
-      return new Schema<>(ImmutableList.copyOf(fields));
+      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
     }
   }
 
@@ -82,14 +88,15 @@
 
   private final ImmutableMap<String, FieldDef<T, ?>> fields;
   private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
+  private final boolean useLegacyNumericFields;
 
   private int version;
 
-  public Schema(Iterable<FieldDef<T, ?>> fields) {
-    this(0, fields);
+  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+    this(0, useLegacyNumericFields, fields);
   }
 
-  public Schema(int version, Iterable<FieldDef<T, ?>> fields) {
+  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
     this.version = version;
     ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
     ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
@@ -101,12 +108,17 @@
     }
     this.fields = b.build();
     this.storedFields = sb.build();
+    this.useLegacyNumericFields = useLegacyNumericFields;
   }
 
   public final int getVersion() {
     return version;
   }
 
+  public final boolean useLegacyNumericFields() {
+    return useLegacyNumericFields;
+  }
+
   /**
    * Get all fields in this schema.
    *
@@ -191,7 +203,7 @@
                 return new Values<>(f, Collections.singleton(v));
               }
             })
-        .filter(Predicates.notNull());
+        .filter(Objects::nonNull);
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index c59f251..9599d6a 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -67,12 +67,19 @@
   }
 
   public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(ImmutableList.copyOf(fields));
+    return new Schema<>(true, ImmutableList.copyOf(fields));
+  }
+
+  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
+    return new Schema<>(
+        useLegacyNumericFields,
+        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
     return new Schema<>(
+        true,
         new ImmutableList.Builder<FieldDef<V, ?>>()
             .addAll(schema.getFields().values())
             .addAll(ImmutableList.copyOf(moreFields))
@@ -81,7 +88,7 @@
 
   @SafeVarargs
   public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return schema(ImmutableList.copyOf(fields));
+    return new Schema<>(true, ImmutableList.copyOf(fields));
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/project/BUILD b/java/com/google/gerrit/index/project/BUILD
index 2c460fd..b423f84 100644
--- a/java/com/google/gerrit/index/project/BUILD
+++ b/java/com/google/gerrit/index/project/BUILD
@@ -5,9 +5,9 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/index/project/IndexedProjectQuery.java b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
index 383ba1c..cdfeabd 100644
--- a/java/com/google/gerrit/index/project/IndexedProjectQuery.java
+++ b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.index.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
 
 public class IndexedProjectQuery extends IndexedQuery<Project.NameKey, ProjectData>
     implements DataSource<ProjectData> {
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index fb029ac..2bf9a4b5 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -18,7 +18,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 119980c..c2c8986 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -20,11 +20,11 @@
 import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 
 /** Index schema for projects. */
 public class ProjectField {
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index db7302a..3e99d55 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.index.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Project;
 
 public interface ProjectIndex extends Index<Project.NameKey, ProjectData> {
 
diff --git a/java/com/google/gerrit/index/project/ProjectIndexCollection.java b/java/com/google/gerrit/index/project/ProjectIndexCollection.java
index 281f992..30227a3 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexCollection.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexCollection.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.index.project;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Singleton;
 
 @Singleton
diff --git a/java/com/google/gerrit/index/project/ProjectIndexer.java b/java/com/google/gerrit/index/project/ProjectIndexer.java
index 1ca29f5..bd5efeb2 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexer.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.index.project;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 public interface ProjectIndexer {
 
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 7d817d2..538e11b 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -78,48 +78,55 @@
     if (source == null) {
       throw new StorageException("No DataSource: " + this);
     }
-    List<T> r = new ArrayList<>();
-    T last = null;
-    int nextStart = 0;
-    boolean skipped = false;
-    for (T data : buffer(source.read())) {
-      if (!isMatchable() || match(data)) {
-        r.add(data);
-      } else {
-        skipped = true;
-      }
-      last = data;
-      nextStart++;
-    }
 
-    if (skipped && last != null && source instanceof Paginated) {
-      // If our source is a paginated source and we skipped at
-      // least one of its results, we may not have filled the full
-      // limit the caller wants.  Restart the source and continue.
-      //
-      @SuppressWarnings("unchecked")
-      Paginated<T> p = (Paginated<T>) source;
-      while (skipped && r.size() < p.getOptions().limit() + start) {
-        skipped = false;
-        ResultSet<T> next = p.restart(nextStart);
-
-        for (T data : buffer(next)) {
-          if (match(data)) {
-            r.add(data);
-          } else {
-            skipped = true;
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    ResultSet<T> resultSet = source.read();
+    return new LazyResultSet<>(
+        () -> {
+          List<T> r = new ArrayList<>();
+          T last = null;
+          int nextStart = 0;
+          boolean skipped = false;
+          for (T data : buffer(resultSet)) {
+            if (!isMatchable() || match(data)) {
+              r.add(data);
+            } else {
+              skipped = true;
+            }
+            last = data;
+            nextStart++;
           }
-          nextStart++;
-        }
-      }
-    }
 
-    if (start >= r.size()) {
-      r = ImmutableList.of();
-    } else if (start > 0) {
-      r = ImmutableList.copyOf(r.subList(start, r.size()));
-    }
-    return new ListResultSet<>(r);
+          if (skipped && last != null && source instanceof Paginated) {
+            // If our source is a paginated source and we skipped at
+            // least one of its results, we may not have filled the full
+            // limit the caller wants.  Restart the source and continue.
+            //
+            @SuppressWarnings("unchecked")
+            Paginated<T> p = (Paginated<T>) source;
+            while (skipped && r.size() < p.getOptions().limit() + start) {
+              skipped = false;
+              ResultSet<T> next = p.restart(nextStart);
+
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                } else {
+                  skipped = true;
+                }
+                nextStart++;
+              }
+            }
+          }
+
+          if (start >= r.size()) {
+            return ImmutableList.of();
+          } else if (start > 0) {
+            return ImmutableList.copyOf(r.subList(start, r.size()));
+          }
+          return ImmutableList.copyOf(r);
+        });
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/LazyResultSet.java b/java/com/google/gerrit/index/query/LazyResultSet.java
new file mode 100644
index 0000000..f3fab5f
--- /dev/null
+++ b/java/com/google/gerrit/index/query/LazyResultSet.java
@@ -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.
+
+package com.google.gerrit.index.query;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Iterator;
+import java.util.function.Supplier;
+
+/**
+ * Result set that allows for asynchronous execution of the actual query. Callers should dispatch
+ * the query and call the constructor of this class with a supplier that fetches the result and
+ * blocks on it if necessary.
+ *
+ * <p>If the execution is synchronous or the results are known a priori, consider using {@link
+ * ListResultSet}.
+ */
+public class LazyResultSet<T> implements ResultSet<T> {
+  private final Supplier<ImmutableList<T>> resultsCallback;
+
+  private boolean resultsReturned = false;
+
+  public LazyResultSet(Supplier<ImmutableList<T>> r) {
+    resultsCallback = requireNonNull(r, "results can't be null");
+  }
+
+  @Override
+  public Iterator<T> iterator() {
+    return toList().iterator();
+  }
+
+  @Override
+  public ImmutableList<T> toList() {
+    if (resultsReturned) {
+      throw new IllegalStateException("Results already obtained");
+    }
+    resultsReturned = true;
+    return resultsCallback.get();
+  }
+
+  @Override
+  public void close() {}
+}
diff --git a/java/com/google/gerrit/index/query/ListResultSet.java b/java/com/google/gerrit/index/query/ListResultSet.java
index 4cf48c8..9d7eadf 100644
--- a/java/com/google/gerrit/index/query/ListResultSet.java
+++ b/java/com/google/gerrit/index/query/ListResultSet.java
@@ -14,15 +14,25 @@
 
 package com.google.gerrit.index.query;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ImmutableList;
 import java.util.Iterator;
 import java.util.List;
 
+/**
+ * Result set for queries that run synchronously or for cases where the result is already known and
+ * we just need to pipe it back through our interfaces.
+ *
+ * <p>If your implementation benefits from asynchronous execution (i.e. dispatching a query and
+ * awaiting results only when {@link ResultSet#toList()} is called, consider using {@link
+ * LazyResultSet}.
+ */
 public class ListResultSet<T> implements ResultSet<T> {
-  private ImmutableList<T> items;
+  private ImmutableList<T> results;
 
   public ListResultSet(List<T> r) {
-    items = ImmutableList.copyOf(r);
+    results = ImmutableList.copyOf(requireNonNull(r, "results can't be null"));
   }
 
   @Override
@@ -32,16 +42,16 @@
 
   @Override
   public ImmutableList<T> toList() {
-    if (items == null) {
+    if (results == null) {
       throw new IllegalStateException("Results already obtained");
     }
-    ImmutableList<T> r = items;
-    items = null;
+    ImmutableList<T> r = results;
+    results = null;
     return r;
   }
 
   @Override
   public void close() {
-    items = null;
+    results = null;
   }
 }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 7077245..9501e52 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.Metadata;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -60,14 +61,15 @@
     final Timer1<String> executionTime;
 
     Metrics(MetricMaker metricMaker) {
-      Field<String> index = Field.ofString("index", "index name");
       executionTime =
           metricMaker.newTimer(
               "query/query_latency",
               new Description("Successful query latency, accumulated over the life of the process")
                   .setCumulative()
                   .setUnit(Description.Units.MILLISECONDS),
-              index);
+              Field.ofString("index", Metadata.Builder::indexName)
+                  .description("index name")
+                  .build());
     }
   }
 
@@ -216,7 +218,7 @@
 
     logger.atFine().log(
         "Executing %d %s index queries for %s",
-        cnt, schemaDef.getName(), callerFinder.findCaller());
+        cnt, schemaDef.getName(), callerFinder.findCallerLazy());
     List<QueryResult<T>> out;
     try {
       // Parse and rewrite all queries.
diff --git a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
new file mode 100644
index 0000000..b0a394e
--- /dev/null
+++ b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+public class TooManyTermsInQueryException extends QueryParseException {
+  private static final long serialVersionUID = 1L;
+
+  private static final String MESSAGE = "too many terms in query";
+
+  public TooManyTermsInQueryException() {
+    super(MESSAGE);
+  }
+
+  public TooManyTermsInQueryException(Throwable why) {
+    super(MESSAGE, why);
+  }
+}
diff --git a/java/com/google/gerrit/index/query/testing/TreeSubject.java b/java/com/google/gerrit/index/query/testing/TreeSubject.java
index c60b363..7d2b868 100644
--- a/java/com/google/gerrit/index/query/testing/TreeSubject.java
+++ b/java/com/google/gerrit/index/query/testing/TreeSubject.java
@@ -23,43 +23,46 @@
 import com.google.gerrit.index.query.QueryParser;
 import org.antlr.runtime.tree.Tree;
 
-public class TreeSubject extends Subject<TreeSubject, Tree> {
+public class TreeSubject extends Subject {
   public static TreeSubject assertThat(Tree actual) {
     return assertAbout(TreeSubject::new).that(actual);
   }
 
+  private final Tree tree;
+
   private TreeSubject(FailureMetadata failureMetadata, Tree tree) {
     super(failureMetadata, tree);
+    this.tree = tree;
   }
 
   public void hasType(int expectedType) {
     isNotNull();
-    check("getType()").that(typeName(actual().getType())).isEqualTo(typeName(expectedType));
+    check("getType()").that(typeName(tree.getType())).isEqualTo(typeName(expectedType));
   }
 
   public void hasText(String expectedText) {
     requireNonNull(expectedText);
     isNotNull();
-    check("getText()").that(actual().getText()).isEqualTo(expectedText);
+    check("getText()").that(tree.getText()).isEqualTo(expectedText);
   }
 
   public void hasNoChildren() {
     isNotNull();
-    check("getChildCount()").that(actual().getChildCount()).isEqualTo(0);
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(0);
   }
 
   public void hasChildCount(int expectedChildCount) {
     checkArgument(
         expectedChildCount > 0, "expected child count must be positive: %s", expectedChildCount);
     isNotNull();
-    check("getChildCount()").that(actual().getChildCount()).isEqualTo(expectedChildCount);
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(expectedChildCount);
   }
 
   public TreeSubject child(int childIndex) {
     isNotNull();
     return check("getChild(%s)", childIndex)
         .about(TreeSubject::new)
-        .that(actual().getChild(childIndex));
+        .that(tree.getChild(childIndex));
   }
 
   private static String typeName(int type) {
diff --git a/java/com/google/gerrit/jgit/BUILD b/java/com/google/gerrit/jgit/BUILD
index e67ebfe..1041f1f 100644
--- a/java/com/google/gerrit/jgit/BUILD
+++ b/java/com/google/gerrit/jgit/BUILD
@@ -8,6 +8,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:gson",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index d9cec45..439f23f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -6,5 +6,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:gson",
+        "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
new file mode 100644
index 0000000..21c4891
--- /dev/null
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -0,0 +1,78 @@
+// 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.json;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.internal.bind.TypeAdapters;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+
+/**
+ * A {@code TypeAdapterFactory} for enums.
+ *
+ * <p>This factory introduces a wrapper around Gson's own default enum handler to add the following
+ * special behavior: log when input which doesn't match any existing enum value is encountered.
+ */
+public class EnumTypeAdapterFactory implements TypeAdapterFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
+    if (defaultEnumAdapter == null) {
+      // Not an enum. -> Enum type adapter doesn't apply.
+      return null;
+    }
+
+    return new EnumTypeAdapter(defaultEnumAdapter, typeToken);
+  }
+
+  private static class EnumTypeAdapter<T extends Enum<T>> extends TypeAdapter<T> {
+
+    private final TypeAdapter<T> defaultEnumAdapter;
+    private final TypeToken<T> typeToken;
+
+    public EnumTypeAdapter(TypeAdapter<T> defaultEnumAdapter, TypeToken<T> typeToken) {
+      this.defaultEnumAdapter = defaultEnumAdapter;
+      this.typeToken = typeToken;
+    }
+
+    @Override
+    public T read(JsonReader in) throws IOException {
+      // Still handle null values. -> Check them first.
+      if (in.peek() == JsonToken.NULL) {
+        in.nextNull();
+        return null;
+      }
+      T enumValue = defaultEnumAdapter.read(in);
+      if (enumValue == null) {
+        logger.atWarning().log("Expected an existing value for enum %s.", typeToken);
+      }
+      return enumValue;
+    }
+
+    @Override
+    public void write(JsonWriter out, T value) throws IOException {
+      defaultEnumAdapter.write(out, value);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/json/OutputFormat.java b/java/com/google/gerrit/json/OutputFormat.java
index a2d174f..3e7c319 100644
--- a/java/com/google/gerrit/json/OutputFormat.java
+++ b/java/com/google/gerrit/json/OutputFormat.java
@@ -55,7 +55,8 @@
     GsonBuilder gb =
         new GsonBuilder()
             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-            .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer());
+            .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer())
+            .registerTypeAdapterFactory(new EnumTypeAdapterFactory());
     if (this == OutputFormat.JSON) {
       gb.setPrettyPrinting();
     }
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
index 0148226..e1cf382 100644
--- a/java/com/google/gerrit/json/SqlTimestampDeserializer.java
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -44,7 +44,15 @@
       throw new JsonParseException("Expected string for timestamp type");
     }
 
-    return JavaSqlTimestampHelper.parseTimestamp(p.getAsString());
+    String input = p.getAsString();
+    if (input.trim().isEmpty()) {
+      // Magic timestamp to indicate no timestamp. (-> null object)
+      // Always create a new object as timestamps are mutable. Don't use TimeUtil.never() to not
+      // introduce an undesired dependency.
+      return new Timestamp(0);
+    }
+
+    return JavaSqlTimestampHelper.parseTimestamp(input);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 7a0430c..deb3203 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -63,8 +63,10 @@
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
+import org.apache.lucene.document.IntPoint;
 import org.apache.lucene.document.LegacyIntField;
 import org.apache.lucene.document.LegacyLongField;
+import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
 import org.apache.lucene.document.TextField;
@@ -334,15 +336,23 @@
 
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
-        doc.add(new LegacyIntField(name, (Integer) value, store));
+        Integer intValue = (Integer) value;
+        if (schema.useLegacyNumericFields()) {
+          doc.add(new LegacyIntField(name, intValue, store));
+        } else {
+          doc.add(new IntPoint(name, intValue));
+          if (store == Store.YES) {
+            doc.add(new StoredField(name, intValue));
+          }
+        }
       }
     } else if (type == FieldType.LONG) {
       for (Object value : values.getValues()) {
-        doc.add(new LegacyLongField(name, (Long) value, store));
+        addLongField(doc, name, store, (Long) value);
       }
     } else if (type == FieldType.TIMESTAMP) {
       for (Object value : values.getValues()) {
-        doc.add(new LegacyLongField(name, ((Timestamp) value).getTime(), store));
+        addLongField(doc, name, store, ((Timestamp) value).getTime());
       }
     } else if (type == FieldType.EXACT || type == FieldType.PREFIX) {
       for (Object value : values.getValues()) {
@@ -361,6 +371,17 @@
     }
   }
 
+  private void addLongField(Document doc, String name, Store store, Long longValue) {
+    if (schema.useLegacyNumericFields()) {
+      doc.add(new LegacyLongField(name, longValue, store));
+    } else {
+      doc.add(new LongPoint(name, longValue));
+      if (store == Store.YES) {
+        doc.add(new StoredField(name, longValue));
+      }
+    }
+  }
+
   protected FieldBundle toFieldBundle(Document doc) {
     Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
     ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 40e3157..879e706 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -24,23 +24,20 @@
     deps = [
         ":query_builder",
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
-        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-core-and-backward-codecs",
         "//lib/lucene:lucene-misc",
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 98424b5..fd439f1 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -27,7 +29,6 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -99,6 +100,9 @@
     if (f == ChangeField.LEGACY_ID) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
+    } else if (f == ChangeField.LEGACY_ID_STR) {
+      String v = (String) getOnlyElement(values.getValues());
+      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 0b787b6..efd7ea3 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -17,8 +17,10 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.server.index.account.AccountField.FULL_NAME;
 import static com.google.gerrit.server.index.account.AccountField.ID;
+import static com.google.gerrit.server.index.account.AccountField.ID_STR;
 import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -27,7 +29,6 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -60,13 +61,18 @@
   private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME);
   private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT);
   private static final String ID_SORT_FIELD = sortFieldName(ID);
+  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR);
 
-  private static Term idTerm(AccountState as) {
-    return idTerm(as.getAccount().getId());
+  private static Term idTerm(boolean useLegacyNumericFields, AccountState as) {
+    return idTerm(useLegacyNumericFields, as.account().id());
   }
 
-  private static Term idTerm(Account.Id id) {
-    return QueryBuilder.intTerm(ID.getName(), id.get());
+  private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
+    FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
+    if (useLegacyNumericFields) {
+      return QueryBuilder.intTerm(idField.getName(), id.get());
+    }
+    return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -110,6 +116,9 @@
     if (f == ID) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
+    } else if (f == ID_STR) {
+      String v = (String) getOnlyElement(values.getValues());
+      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
     } else if (f == FULL_NAME) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
@@ -123,7 +132,7 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(as), toDocument(as)).get();
+      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -132,7 +141,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(key)).get();
+      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -141,20 +150,29 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
+    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(IndexUtils::accountFields), queryBuilder.toQuery(p), getSort());
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        queryBuilder.toQuery(p),
+        getSort());
   }
 
   private Sort getSort() {
+    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
-        new SortField(ID_SORT_FIELD, SortField.Type.LONG, false));
+        new SortField(idSortField, SortField.Type.LONG, false));
   }
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    Account.Id id =
+        Account.id(
+            getSchema().useLegacyNumericFields()
+                ? doc.getField(idField.getName()).numericValue().intValue()
+                : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
     // there shouldn't be any). The most expensive part to compute anyway is the effective group
     // IDs, and we don't have a good way to reindex when those change.
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 87f9396..e576d73 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -18,13 +18,13 @@
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
@@ -36,7 +36,16 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
@@ -44,14 +53,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
-import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -77,6 +78,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
@@ -106,6 +108,7 @@
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
+  static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
@@ -134,12 +137,22 @@
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
 
-  static Term idTerm(ChangeData cd) {
-    return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
+  @FunctionalInterface
+  static interface IdTerm {
+    Term get(String name, int id);
   }
 
-  static Term idTerm(Change.Id id) {
-    return QueryBuilder.intTerm(LEGACY_ID.getName(), id.get());
+  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
+    return idTerm(idTerm, idField, cd.getId());
+  }
+
+  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
+    return idTerm.get(idField.getName(), id.get());
+  }
+
+  @FunctionalInterface
+  static interface ChangeIdExtractor {
+    Change.Id extract(IndexableField f);
   }
 
   private final ListeningExecutorService executor;
@@ -149,6 +162,12 @@
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
 
+  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
+  private final FieldDef<ChangeData, ?> idField;
+  private final String idSortFieldName;
+  private final IdTerm idTerm;
+  private final ChangeIdExtractor extractor;
+
   @Inject
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
@@ -183,6 +202,20 @@
           new ChangeSubIndex(
               schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
     }
+
+    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
+    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    idTerm =
+        (name, id) ->
+            this.schema.useLegacyNumericFields()
+                ? QueryBuilder.intTerm(name, id)
+                : QueryBuilder.stringTerm(name, Integer.toString(id));
+    extractor =
+        (f) ->
+            Change.id(
+                this.schema.useLegacyNumericFields()
+                    ? f.numericValue().intValue()
+                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -201,7 +234,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(cd);
+    Term id = LuceneChangeIndex.idTerm(idTerm, idField, cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
@@ -217,10 +250,10 @@
   }
 
   @Override
-  public void delete(Change.Id id) {
-    Term idTerm = LuceneChangeIndex.idTerm(id);
+  public void delete(Change.Id changeId) {
+    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
     try {
-      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
+      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -256,11 +289,7 @@
   private Sort getSort() {
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
-  }
-
-  public ChangeSubIndex getClosedChangesIndex() {
-    return closedIndex;
+        new SortField(idSortFieldName, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -308,7 +337,7 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts);
+      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
       return new ChangeDataResults(
           executor.submit(
               new Callable<List<Document>>() {
@@ -329,7 +358,7 @@
     public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
       try {
-        documents = doRead(IndexUtils.changeFields(opts));
+        documents = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
       } catch (IOException e) {
         throw new StorageException(e);
       }
@@ -407,9 +436,8 @@
         List<Document> docs = future.get();
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
-        String idFieldName = LEGACY_ID.getName();
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idFieldName));
+          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -450,10 +478,10 @@
       cd = changeDataFactory.create(parseProtoFrom(proto, ChangeProtoConverter.INSTANCE));
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
-      Change.Id id = new Change.Id(f.numericValue().intValue());
+
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(new Project.NameKey(project.stringValue()), id);
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
     }
 
     // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
@@ -556,7 +584,7 @@
         if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
           break;
         }
-        accounts.add(new Account.Id(id));
+        accounts.add(Account.id(id));
       }
       cd.setReviewedBy(accounts);
     }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 0fdef77..99cd40d 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.server.index.group.GroupField.UUID;
 
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -25,7 +26,6 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -139,7 +139,7 @@
 
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 950e206..97454c7 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.index.project.ProjectField.NAME;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -27,7 +28,6 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
@@ -139,7 +139,7 @@
 
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
     ProjectState projectState = projectCache.get().get(nameKey);
     return projectState == null ? null : projectState.toProjectData();
   }
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 8dfc12e..7d82bf5 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -36,6 +36,8 @@
 import java.util.Date;
 import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
 import org.apache.lucene.search.LegacyNumericRangeQuery;
@@ -49,6 +51,21 @@
 
 @SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
+  @FunctionalInterface
+  static interface IntTermQuery {
+    Query get(String name, int value);
+  }
+
+  @FunctionalInterface
+  static interface IntRangeQuery {
+    Query get(String name, int min, int max);
+  }
+
+  @FunctionalInterface
+  static interface LongRangeQuery {
+    Query get(String name, long min, long max);
+  }
+
   static Term intTerm(String name, int value) {
     BytesRefBuilder builder = new BytesRefBuilder();
     LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
@@ -61,12 +78,36 @@
     return new Term(name, builder.get());
   }
 
+  static Query intPoint(String name, int value) {
+    return IntPoint.newExactQuery(name, value);
+  }
+
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
+  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
+  private final IntTermQuery intTermQuery;
+  private final IntRangeQuery intRangeTermQuery;
+  private final LongRangeQuery longRangeQuery;
+
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
+    intTermQuery =
+        (name, value) ->
+            this.schema.useLegacyNumericFields()
+                ? new TermQuery(intTerm(name, value))
+                : intPoint(name, value);
+    intRangeTermQuery =
+        (name, min, max) ->
+            this.schema.useLegacyNumericFields()
+                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
+                : IntPoint.newRangeQuery(name, min, max);
+    longRangeQuery =
+        (name, min, max) ->
+            this.schema.useLegacyNumericFields()
+                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
+                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -169,20 +210,20 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return new TermQuery(intTerm(p.getField().getName(), value));
+    return intTermQuery.get(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof IntegerRangePredicate) {
       IntegerRangePredicate<V> r = (IntegerRangePredicate<V>) p;
+      String name = r.getField().getName();
       int minimum = r.getMinimumValue();
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return new TermQuery(intTerm(p.getField().getName(), minimum));
+        return intTermQuery.get(name, minimum);
       }
-      return LegacyNumericRangeQuery.newIntRange(
-          r.getField().getName(), minimum, maximum, true, true);
+      return intRangeTermQuery.get(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -190,20 +231,16 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return LegacyNumericRangeQuery.newLongRange(
-          r.getField().getName(),
-          r.getMinTimestamp().getTime(),
-          r.getMaxTimestamp().getTime(),
-          true,
-          true);
+      return longRangeQuery.get(
+          r.getField().getName(), r.getMinTimestamp().getTime(), r.getMaxTimestamp().getTime());
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
-      return LegacyNumericRangeQuery.newLongRange(
-          r.getField().getName(), r.getMaxTimestamp().getTime(), null, true, true);
+      return longRangeQuery.get(
+          r.getField().getName(), r.getMaxTimestamp().getTime(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -245,4 +282,8 @@
   public int toIndexTimeInMinutes(Date ts) {
     return (int) (ts.getTime() / 60000);
   }
+
+  public Schema<V> getSchema() {
+    return schema;
+  }
 }
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index ba8d7da..4044b90 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -177,7 +177,7 @@
    * Expert: creates a searcher from the provided {@link IndexReader} using the provided {@link
    * SearcherFactory}. NOTE: this decRefs incoming reader on throwing an exception.
    */
-  @SuppressWarnings("resource")
+  @SuppressWarnings({"resource", "ReferenceEquality"})
   public static IndexSearcher getSearcher(SearcherFactory searcherFactory, IndexReader reader)
       throws IOException {
     boolean success = false;
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 6be5f0e..46bc77a 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -6,7 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 265c412..7905a0a 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
index fd8198c..f024f17 100644
--- a/java/com/google/gerrit/mail/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.Objects;
 
 /** A comment parsed from inbound email */
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
index 6a27ac4..4b292f3 100644
--- a/java/com/google/gerrit/mail/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.List;
 import java.util.StringJoiner;
 import java.util.regex.Pattern;
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
index 1a63599..dac3deb 100644
--- a/java/com/google/gerrit/mail/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 786ef68..3cc056b 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -8,9 +8,12 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index adf6a66..1fb8c57 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>(name) {
+    return new Timer1<F1>(name, field1) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>(name) {
+    return new Timer2<F1, F2>(name, field1, field2) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>(name) {
+    return new Timer3<F1, F2, F3>(name, field1, field2, field3) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index 95eb9cf..bdae854 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -16,34 +16,36 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
-import com.google.common.base.Functions;
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.logging.Metadata;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 /**
  * Describes a bucketing field used by a metric.
  *
  * @param <T> type of field
  */
-public class Field<T> {
-  /**
-   * Break down metrics by boolean true/false.
-   *
-   * @param name field name
-   * @return boolean field
-   */
-  public static Field<Boolean> ofBoolean(String name) {
-    return ofBoolean(name, null);
+@AutoValue
+public abstract class Field<T> {
+  public static <T> BiConsumer<Metadata.Builder, T> ignoreMetadata() {
+    return (metadataBuilder, fieldValue) -> {};
   }
 
   /**
    * Break down metrics by boolean true/false.
    *
    * @param name field name
-   * @param description field description
-   * @return boolean field
+   * @return builder for the boolean field
    */
-  public static Field<Boolean> ofBoolean(String name, String description) {
-    return new Field<>(name, Boolean.class, description);
+  public static Field.Builder<Boolean> ofBoolean(
+      String name, BiConsumer<Metadata.Builder, Boolean> metadataMapper) {
+    return new AutoValue_Field.Builder<Boolean>()
+        .valueType(Boolean.class)
+        .formatter(Object::toString)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /**
@@ -51,50 +53,17 @@
    *
    * @param enumType type of enum
    * @param name field name
-   * @return enum field
+   * @return builder for the enum field
    */
-  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType, String name) {
-    return ofEnum(enumType, name, null);
-  }
-
-  /**
-   * Break down metrics by cases of an enum.
-   *
-   * @param enumType type of enum
-   * @param name field name
-   * @param description field description
-   * @return enum field
-   */
-  public static <E extends Enum<E>> Field<E> ofEnum(
-      Class<E> enumType, String name, String description) {
-    return new Field<>(name, enumType, description);
-  }
-
-  /**
-   * Break down metrics by string.
-   *
-   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
-   * value</b> as field values are never reclaimed.
-   *
-   * @param name field name
-   * @return string field
-   */
-  public static Field<String> ofString(String name) {
-    return ofString(name, null);
-  }
-
-  /**
-   * Break down metrics by string.
-   *
-   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
-   * value</b> as field values are never reclaimed.
-   *
-   * @param name field name
-   * @param description field description
-   * @return string field
-   */
-  public static Field<String> ofString(String name, String description) {
-    return new Field<>(name, String.class, description);
+  public static <E extends Enum<E>> Field.Builder<E> ofEnum(
+      Class<E> enumType, String name, BiConsumer<Metadata.Builder, String> metadataMapper) {
+    return new AutoValue_Field.Builder<E>()
+        .valueType(enumType)
+        .formatter(Enum::name)
+        .name(name)
+        .metadataMapper(
+            (metadataBuilder, fieldValue) ->
+                metadataMapper.accept(metadataBuilder, fieldValue.name()));
   }
 
   /**
@@ -104,67 +73,68 @@
    * value</b> as field values are never reclaimed.
    *
    * @param name field name
-   * @return integer field
+   * @return builder for the integer field
    */
-  public static Field<Integer> ofInteger(String name) {
-    return ofInteger(name, null);
+  public static Field.Builder<Integer> ofInteger(
+      String name, BiConsumer<Metadata.Builder, Integer> metadataMapper) {
+    return new AutoValue_Field.Builder<Integer>()
+        .valueType(Integer.class)
+        .formatter(Object::toString)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /**
-   * Break down metrics by integer.
+   * Break down metrics by string.
    *
-   * <p>Each unique integer will allocate a new submetric. <b>Do not use user content as a field
+   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
    * value</b> as field values are never reclaimed.
    *
    * @param name field name
-   * @param description field description
-   * @return integer field
+   * @return builder for the string field
    */
-  public static Field<Integer> ofInteger(String name, String description) {
-    return new Field<>(name, Integer.class, description);
-  }
-
-  private final String name;
-  private final Class<T> keyType;
-  private final Function<T, String> formatter;
-  private final String description;
-
-  private Field(String name, Class<T> keyType, String description) {
-    checkArgument(name.matches("^[a-z_]+$"), "name must match [a-z_]");
-    this.name = name;
-    this.keyType = keyType;
-    this.formatter = initFormatter(keyType);
-    this.description = description;
+  public static Field.Builder<String> ofString(
+      String name, BiConsumer<Metadata.Builder, String> metadataMapper) {
+    return new AutoValue_Field.Builder<String>()
+        .valueType(String.class)
+        .formatter(s -> s)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /** @return name of this field within the metric. */
-  public String getName() {
-    return name;
-  }
+  public abstract String name();
 
   /** @return type of value used within the field. */
-  public Class<T> getType() {
-    return keyType;
-  }
+  public abstract Class<T> valueType();
+
+  /** @return mapper that maps a field value to a field in the {@link Metadata} class. */
+  public abstract BiConsumer<Metadata.Builder, T> metadataMapper();
 
   /** @return description text for the field explaining its range of values. */
-  public String getDescription() {
-    return description;
-  }
+  public abstract Optional<String> description();
 
-  public Function<T, String> formatter() {
-    return formatter;
-  }
+  /** @return formatter to format field values. */
+  public abstract Function<T, String> formatter();
 
-  @SuppressWarnings("unchecked")
-  private static <T> Function<T, String> initFormatter(Class<T> keyType) {
-    if (keyType == String.class) {
-      return (Function<T, String>) Functions.<String>identity();
-    } else if (keyType == Integer.class || keyType == Boolean.class) {
-      return (Function<T, String>) Functions.toStringFunction();
-    } else if (Enum.class.isAssignableFrom(keyType)) {
-      return in -> ((Enum<?>) in).name();
+  @AutoValue.Builder
+  public abstract static class Builder<T> {
+    abstract Builder<T> name(String name);
+
+    abstract Builder<T> valueType(Class<T> type);
+
+    abstract Builder<T> formatter(Function<T, String> formatter);
+
+    abstract Builder<T> metadataMapper(BiConsumer<Metadata.Builder, T> metadataMapper);
+
+    public abstract Builder<T> description(String description);
+
+    abstract Field<T> autoBuild();
+
+    public Field<T> build() {
+      Field<T> field = autoBuild();
+      checkArgument(field.name().matches("^[a-z_]+$"), "name must match [a-z_]");
+      return field;
     }
-    throw new IllegalStateException("unsupported type " + keyType.getName());
   }
 }
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 2134488..d0033a4 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -18,6 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -68,7 +70,10 @@
    * @param unit time unit of the value
    */
   public final void record(long value, TimeUnit unit) {
-    logger.atFinest().log("%s took %dms", name, unit.toMillis(value));
+    long durationMs = unit.toMillis(value);
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
+    logger.atFinest().log("%s took %dms", name, durationMs);
     doRecord(value, unit);
   }
 
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index 16c151e..a8fb1a2 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -18,6 +18,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -35,56 +38,66 @@
 public abstract class Timer1<F1> implements RegistrationHandle {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Context extends TimerContext {
-    private final Timer1<Object> timer;
-    private final Object field1;
+  public static class Context<F1> extends TimerContext {
+    private final Timer1<F1> timer;
+    private final F1 fieldValue;
 
-    @SuppressWarnings("unchecked")
-    <F1> Context(Timer1<F1> timer, F1 field1) {
-      this.timer = (Timer1<Object>) timer;
-      this.field1 = field1;
+    Context(Timer1<F1> timer, F1 fieldValue) {
+      this.timer = timer;
+      this.fieldValue = fieldValue;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, elapsed, NANOSECONDS);
+      timer.record(fieldValue, elapsed, NANOSECONDS);
     }
   }
 
   protected final String name;
+  protected final Field<F1> field;
 
-  public Timer1(String name) {
+  public Timer1(String name, Field<F1> field) {
     this.name = name;
+    this.field = field;
   }
 
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
+   * @param fieldValue bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1) {
-    return new Context(this, field1);
+  public Context<F1> start(F1 fieldValue) {
+    return new Context<>(this, fieldValue);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
+   * @param fieldValue bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public final void record(F1 field1, long value, TimeUnit unit) {
-    logger.atFinest().log("%s (%s) took %dms", name, field1, unit.toMillis(value));
-    doRecord(field1, value, unit);
+  public final void record(F1 fieldValue, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field.metadataMapper().accept(metadataBuilder, fieldValue);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
+    logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
+    doRecord(fieldValue, value, unit);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
+   * @param fieldValue bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  protected abstract void doRecord(F1 field1, long value, TimeUnit unit);
+  protected abstract void doRecord(F1 fieldValue, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index bf19448..8a4a793 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -18,6 +18,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -36,61 +39,76 @@
 public abstract class Timer2<F1, F2> implements RegistrationHandle {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Context extends TimerContext {
-    private final Timer2<Object, Object> timer;
-    private final Object field1;
-    private final Object field2;
+  public static class Context<F1, F2> extends TimerContext {
+    private final Timer2<F1, F2> timer;
+    private final F1 fieldValue1;
+    private final F2 fieldValue2;
 
-    @SuppressWarnings("unchecked")
-    <F1, F2> Context(Timer2<F1, F2> timer, F1 field1, F2 field2) {
-      this.timer = (Timer2<Object, Object>) timer;
-      this.field1 = field1;
-      this.field2 = field2;
+    Context(Timer2<F1, F2> timer, F1 fieldValue1, F2 fieldValue2) {
+      this.timer = timer;
+      this.fieldValue1 = fieldValue1;
+      this.fieldValue2 = fieldValue2;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, field2, elapsed, NANOSECONDS);
+      timer.record(fieldValue1, fieldValue2, elapsed, NANOSECONDS);
     }
   }
 
   protected final String name;
+  protected final Field<F1> field1;
+  protected final Field<F2> field2;
 
-  public Timer2(String name) {
+  public Timer2(String name, Field<F1> field1, Field<F2> field2) {
     this.name = name;
+    this.field1 = field1;
+    this.field2 = field2;
   }
 
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1, F2 field2) {
-    return new Context(this, field1, field2);
+  public Context<F1, F2> start(F1 fieldValue1, F2 fieldValue2) {
+    return new Context<>(this, fieldValue1, fieldValue2);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public final void record(F1 field1, F2 field2, long value, TimeUnit unit) {
-    logger.atFinest().log("%s (%s, %s) took %dms", name, field1, field2, unit.toMillis(value));
-    doRecord(field1, field2, value, unit);
+  public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field1.metadataMapper().accept(metadataBuilder, fieldValue1);
+    field2.metadataMapper().accept(metadataBuilder, fieldValue2);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
+    logger.atFinest().log(
+        "%s (%s = %s, %s = %s) took %dms",
+        name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
+    doRecord(fieldValue1, fieldValue2, value, unit);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  protected abstract void doRecord(F1 field1, F2 field2, long value, TimeUnit unit);
+  protected abstract void doRecord(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index c910eb0..2044da6 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -18,6 +18,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -37,67 +40,93 @@
 public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Context extends TimerContext {
-    private final Timer3<Object, Object, Object> timer;
-    private final Object field1;
-    private final Object field2;
-    private final Object field3;
+  public static class Context<F1, F2, F3> extends TimerContext {
+    private final Timer3<F1, F2, F3> timer;
+    private final F1 fieldValue1;
+    private final F2 fieldValue2;
+    private final F3 fieldValue3;
 
-    @SuppressWarnings("unchecked")
-    <F1, F2, F3> Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
-      this.timer = (Timer3<Object, Object, Object>) timer;
-      this.field1 = f1;
-      this.field2 = f2;
-      this.field3 = f3;
+    Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
+      this.timer = timer;
+      this.fieldValue1 = f1;
+      this.fieldValue2 = f2;
+      this.fieldValue3 = f3;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, field2, field3, elapsed, NANOSECONDS);
+      timer.record(fieldValue1, fieldValue2, fieldValue3, elapsed, NANOSECONDS);
     }
   }
 
   protected final String name;
+  protected final Field<F1> field1;
+  protected final Field<F2> field2;
+  protected final Field<F3> field3;
 
-  public Timer3(String name) {
+  public Timer3(String name, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     this.name = name;
+    this.field1 = field1;
+    this.field2 = field2;
+    this.field3 = field3;
   }
 
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
-   * @param field3 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1, F2 field2, F3 field3) {
-    return new Context(this, field1, field2, field3);
+  public Context<F1, F2, F3> start(F1 fieldValue1, F2 fieldValue2, F3 fieldValue3) {
+    return new Context<>(this, fieldValue1, fieldValue2, fieldValue3);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
-   * @param field3 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public final void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
+  public final void record(
+      F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field1.metadataMapper().accept(metadataBuilder, fieldValue1);
+    field2.metadataMapper().accept(metadataBuilder, fieldValue2);
+    field3.metadataMapper().accept(metadataBuilder, fieldValue3);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
     logger.atFinest().log(
-        "%s (%s, %s, %s) took %dms", name, field1, field2, field3, unit.toMillis(value));
-    doRecord(field1, field2, field3, value, unit);
+        "%s (%s = %s, %s = %s, %s = %s) took %dms",
+        name,
+        field1.name(),
+        fieldValue1,
+        field2.name(),
+        fieldValue2,
+        field3.name(),
+        fieldValue3,
+        durationMs);
+    doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
-   * @param field3 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  protected abstract void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit);
+  protected abstract void doRecord(
+      F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
index 6d1daf4..d718035 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.codahale.metrics.MetricRegistry;
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCallback} for single dimension. */
 class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 46434ce..0e554a8 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCounter} for single dimension. */
 class CounterImpl1<F1> extends BucketedCounter {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 38c31a1..07afc2a 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional counter metrics. */
 class CounterImplN extends BucketedCounter implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index ae1e6ec..12dabfa 100644
--- a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,10 +37,10 @@
   }
 
   @Override
-  public MetricJson apply(MetricResource resource)
+  public Response<MetricJson> apply(MetricResource resource)
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
-    return new MetricJson(
-        resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
+    return Response.ok(
+        new MetricJson(resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly));
   }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 3eb12fa..4578db1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram1;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedHistogram} for single dimension. */
 class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 3561c55a..446590c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram2;
 import com.google.gerrit.metrics.Histogram3;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional Histogram metrics. */
 class HistogramImplN extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 0c69452..7e472c9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -16,6 +16,7 @@
 
 import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -50,7 +51,7 @@
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource)
+  public Response<Map<String, MetricJson>> apply(ConfigResource resource)
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
@@ -75,7 +76,7 @@
       }
     }
 
-    return out;
+    return Response.ok(out);
   }
 
   private MetricJson toJson(String q, Metric m) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index 20f4fa3..d59a1d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -21,7 +21,6 @@
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.Snapshot;
 import com.codahale.metrics.Timer;
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.metrics.Description;
@@ -30,6 +29,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Function;
 
 class MetricJson {
   String description;
@@ -189,10 +189,10 @@
     String description;
 
     FieldJson(Field<?> field) {
-      this.name = field.getName();
-      this.description = field.getDescription();
+      this.name = field.name();
+      this.description = field.description().orElse(null);
       this.type =
-          Enum.class.isAssignableFrom(field.getType()) ? field.getType().getSimpleName() : null;
+          Enum.class.isAssignableFrom(field.valueType()) ? field.valueType().getSimpleName() : null;
     }
   }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index d97e73a..b7d535b 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer1;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedTimer} for single dimension. */
 class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
@@ -26,8 +26,9 @@
     super(metrics, name, desc, field1);
   }
 
+  @SuppressWarnings("unchecked")
   Timer1<F1> timer() {
-    return new Timer1<F1>(name) {
+    return new Timer1<F1>(name, (Field<F1>) fields[0]) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index be66009..dee800e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
@@ -22,6 +21,7 @@
 import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.metrics.Timer3;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional timer metrics. */
 class TimerImplN extends BucketedTimer implements BucketedMetric {
@@ -29,8 +29,9 @@
     super(metrics, name, desc, fields);
   }
 
+  @SuppressWarnings("unchecked")
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>(name) {
+    return new Timer2<F1, F2>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -44,8 +45,10 @@
     };
   }
 
+  @SuppressWarnings("unchecked")
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>(name) {
+    return new Timer3<F1, F2, F3>(
+        name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
index a44f907..e8611b3 100644
--- a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import java.util.Map;
 import org.eclipse.jgit.storage.file.WindowCacheStats;
 
@@ -183,7 +184,7 @@
                         + "having most data in the cache.")
                 .setGauge()
                 .setUnit("byte"),
-            Field.ofString("repository_name"));
+            Field.ofString("repository_name", Metadata.Builder::projectName).build());
     metrics.newTrigger(
         repoEnt,
         () -> {
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 2c29882..20ac8fa 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import java.lang.management.GarbageCollectorMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.MemoryMXBean;
@@ -167,12 +168,17 @@
   }
 
   private void procJvmGc(MetricMaker metrics) {
+    Field<String> gcNameField =
+        Field.ofString("gc_name", Metadata.Builder::garbageCollectorName)
+            .description("The name of the garbage collector")
+            .build();
+
     CallbackMetric1<String, Long> gcCount =
         metrics.newCallbackMetric(
             "proc/jvm/gc/count",
             Long.class,
             new Description("Number of GCs").setCumulative(),
-            Field.ofString("gc_name", "The name of the garbage collector"));
+            gcNameField);
 
     CallbackMetric1<String, Long> gcTime =
         metrics.newCallbackMetric(
@@ -181,7 +187,7 @@
             new Description("Approximate accumulated GC elapsed time")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofString("gc_name", "The name of the garbage collector"));
+            gcNameField);
 
     metrics.newTrigger(
         gcCount,
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index adc13f7..8b8f13c 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -1,17 +1,7 @@
 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
-# is fixed.
-alias(
-    name = "pgm",
-    actual = ":daemon",
-    visibility = ["//visibility:public"],
-)
-
 java_library(
-    name = "daemon",
+    name = "pgm",
     srcs = glob(["**/*.java"]),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/pgm"],
@@ -20,6 +10,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
@@ -37,7 +28,6 @@
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/pgm/init/api",
         "//java/com/google/gerrit/pgm/util",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
@@ -51,15 +41,15 @@
         "//java/com/google/gerrit/sshd",
         "//lib:args4j",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
-        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
         "//lib/prolog:cafeteria",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 6553477..2457b6a 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
@@ -83,7 +84,6 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.OnlineUpgrader;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
@@ -149,8 +149,11 @@
     sshd = false;
   }
 
-  @Option(name = "--slave", usage = "Support fetch only")
-  private boolean slave;
+  @Option(
+      name = "--replica",
+      aliases = {"--slave"},
+      usage = "Support fetch only")
+  private boolean replica;
 
   @Option(name = "--console-log", usage = "Log to console (not $site_path/logs)")
   private boolean consoleLog;
@@ -216,8 +219,8 @@
     httpd = enable;
   }
 
-  public void setSlave(boolean slave) {
-    this.slave = slave;
+  public void setReplica(boolean replica) {
+    this.replica = replica;
   }
 
   @Override
@@ -242,7 +245,7 @@
     }
 
     if (httpd == null) {
-      httpd = !slave;
+      httpd = !replica;
     }
 
     if (!httpd && !sshd) {
@@ -326,7 +329,7 @@
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    initIndexType();
+    indexType = IndexModule.getIndexType(cfgInjector);
     sysInjector = createSysInjector();
     sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
     manager.add(dbInjector, cfgInjector, sysInjector);
@@ -370,8 +373,8 @@
 
   private String myVersion() {
     List<String> versionParts = new ArrayList<>();
-    if (slave) {
-      versionParts.add("[slave]");
+    if (replica) {
+      versionParts.add("[replica]");
     }
     if (headless) {
       versionParts.add("[headless]");
@@ -407,7 +410,7 @@
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
 
-    modules.add(new SearchingChangeCacheImpl.Module(slave));
+    modules.add(new SearchingChangeCacheImpl.Module(replica));
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
@@ -459,7 +462,8 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(headless, slave, polyGerritDev));
+            bind(GerritOptions.class)
+                .toInstance(new GerritOptions(headless, replica, polyGerritDev));
             if (inMemoryTest) {
               bind(String.class)
                   .annotatedWith(SecureStoreClassName.class)
@@ -469,7 +473,7 @@
           }
         });
     modules.add(new GarbageCollectionModule());
-    if (slave) {
+    if (replica) {
       modules.add(new PeriodicGroupIndexer.Module());
     } else {
       modules.add(new AccountDeactivator.Module());
@@ -487,25 +491,13 @@
     if (luceneModule != null) {
       return luceneModule;
     }
-    switch (indexType) {
-      case LUCENE:
-        return LuceneIndexModule.latestVersion(slave);
-      case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersion(slave);
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (indexType.isLucene()) {
+      return LuceneIndexModule.latestVersion(replica);
     }
-  }
-
-  private void initIndexType() {
-    indexType = IndexModule.getIndexType(cfgInjector);
-    switch (indexType) {
-      case LUCENE:
-      case ELASTICSEARCH:
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (indexType.isElasticsearch()) {
+      return ElasticIndexModule.latestVersion(replica);
     }
+    throw new IllegalStateException("unsupported index.type = " + indexType);
   }
 
   private void initSshd() {
@@ -522,10 +514,10 @@
     }
     modules.add(
         new DefaultCommandModule(
-            slave,
+            replica,
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (!slave) {
+    if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
     }
     return sysInjector.createChildInjector(modules);
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 0537fe9..799377c 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
+import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -144,7 +145,7 @@
         });
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
-    if (!run.flags.cfg.getBoolean("container", "slave", false)) {
+    if (!ReplicaUtil.isReplica(run.flags.cfg)) {
       reindexProjects();
     }
     start(run);
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 0e5f659..2e526bb 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -31,9 +32,9 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -144,19 +145,17 @@
     if (changesVersion != null) {
       versions.put(ChangeSchemaDefinitions.INSTANCE.getName(), changesVersion);
     }
-    boolean slave = globalConfig.getBoolean("container", "slave", false);
+    boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
     Module indexModule;
-    switch (IndexModule.getIndexType(dbInjector)) {
-      case LUCENE:
-        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
-        break;
-      case ELASTICSEARCH:
-        indexModule =
-            ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type");
+    IndexType indexType = IndexModule.getIndexType(dbInjector);
+    if (indexType.isLucene()) {
+      indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+    } else if (indexType.isElasticsearch()) {
+      indexModule =
+          ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+    } else {
+      throw new IllegalStateException("unsupported index.type = " + indexType);
     }
     modules.add(indexModule);
     modules.add(new BatchProgramModule());
@@ -173,7 +172,7 @@
 
   private void overrideConfig() {
     // Disable auto-commit for speed; committing will happen at the end of the process.
-    if (IndexModule.getIndexType(dbInjector) == IndexType.LUCENE) {
+    if (IndexModule.getIndexType(dbInjector).isLucene()) {
       globalConfig.setLong("index", "changes_open", "commitWithin", -1);
       globalConfig.setLong("index", "changes_closed", "commitWithin", -1);
     }
diff --git a/java/com/google/gerrit/pgm/Rulec.java b/java/com/google/gerrit/pgm/Rulec.java
index aa72ae0..d695217 100644
--- a/java/com/google/gerrit/pgm/Rulec.java
+++ b/java/com/google/gerrit/pgm/Rulec.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.rules.PrologCompiler;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -73,7 +73,7 @@
 
     LinkedHashSet<Project.NameKey> names = new LinkedHashSet<>();
     for (String name : projectNames) {
-      names.add(new Project.NameKey(name));
+      names.add(Project.nameKey(name));
     }
     if (all) {
       names.addAll(gitManager.list());
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
index e1e1a0b..cd188f5 100644
--- a/java/com/google/gerrit/pgm/http/jetty/BUILD
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -16,7 +16,8 @@
         "//java/com/google/gerrit/util/logging",
         "//lib:gson",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
@@ -24,7 +25,6 @@
         "//lib/jetty:jmx",
         "//lib/jetty:server",
         "//lib/jetty:servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index ff94905..536ddcd 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.Accounts;
@@ -60,63 +60,59 @@
     this.allUsers = allUsers.get();
   }
 
-  public void insert(Account account) throws IOException {
+  public Account insert(Account.Builder account) throws IOException {
     File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path);
-          ObjectInserter oi = repo.newObjectInserter()) {
-        PersonIdent ident =
-            new PersonIdent(
-                new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent ident =
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
 
-        Config accountConfig = new Config();
-        AccountProperties.writeToAccountConfig(
-            InternalAccountUpdate.builder()
-                .setActive(account.isActive())
-                .setFullName(account.getFullName())
-                .setPreferredEmail(account.getPreferredEmail())
-                .setStatus(account.getStatus())
-                .build(),
-            accountConfig);
+      Config accountConfig = new Config();
+      AccountProperties.writeToAccountConfig(
+          InternalAccountUpdate.builder()
+              .setActive(!account.inactive())
+              .setFullName(account.fullName())
+              .setPreferredEmail(account.preferredEmail())
+              .setStatus(account.status())
+              .build(),
+          accountConfig);
 
-        DirCache newTree = DirCache.newInCore();
-        DirCacheEditor editor = newTree.editor();
-        final ObjectId blobId =
-            oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
-        editor.add(
-            new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
-              @Override
-              public void apply(DirCacheEntry ent) {
-                ent.setFileMode(FileMode.REGULAR_FILE);
-                ent.setObjectId(blobId);
-              }
-            });
-        editor.finish();
+      DirCache newTree = DirCache.newInCore();
+      DirCacheEditor editor = newTree.editor();
+      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+      editor.add(
+          new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
+      editor.finish();
 
-        ObjectId treeId = newTree.writeTree(oi);
+      ObjectId treeId = newTree.writeTree(oi);
 
-        CommitBuilder cb = new CommitBuilder();
-        cb.setTreeId(treeId);
-        cb.setCommitter(ident);
-        cb.setAuthor(ident);
-        cb.setMessage("Create Account");
-        ObjectId id = oi.insert(cb);
-        oi.flush();
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage("Create Account");
+      ObjectId id = oi.insert(cb);
+      oi.flush();
 
-        String refName = RefNames.refsUsers(account.getId());
-        RefUpdate ru = repo.updateRef(refName);
-        ru.setExpectedOldObjectId(ObjectId.zeroId());
-        ru.setNewObjectId(id);
-        ru.setRefLogIdent(ident);
-        ru.setRefLogMessage("Create Account", false);
-        Result result = ru.update();
-        if (result != Result.NEW) {
-          throw new IOException(
-              String.format("Failed to update ref %s: %s", refName, result.name()));
-        }
-        account.setMetaId(id.name());
+      String refName = RefNames.refsUsers(account.id());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(id);
+      ru.setRefLogIdent(ident);
+      ru.setRefLogMessage("Create Account", false);
+      Result result = ru.update();
+      if (result != Result.NEW) {
+        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
+      account.setMetaId(id.name()).build();
     }
+    return account.build();
   }
 
   public boolean hasAnyAccount() throws IOException {
@@ -132,7 +128,10 @@
 
   private File getPath() {
     Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    checkArgument(basePath != null, "gerrit.basePath must be configured");
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    requireNonNull(basePath, "gerrit.basePath must be configured");
+    File file = basePath.resolve(allUsers).toFile();
+    File resolvedFile = FileKey.resolve(file, FS.DETECTED);
+    requireNonNull(resolvedFile, () -> String.format("%s does not exist", file.getAbsolutePath()));
+    return resolvedFile;
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index eb0d49e..62c9526 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -10,6 +10,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
@@ -18,17 +19,16 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm/init/api",
         "//java/com/google/gerrit/pgm/util",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
+        "//lib:jgit",
         "//lib/commons:validator",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 0bc1860..62ff66a 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Die;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -412,15 +413,13 @@
           });
       Injector dbInjector = createDbInjector();
 
-      switch (IndexModule.getIndexType(dbInjector)) {
-        case LUCENE:
-          modules.add(new LuceneIndexModuleOnInit());
-          break;
-        case ELASTICSEARCH:
-          modules.add(new ElasticIndexModuleOnInit());
-          break;
-        default:
-          throw new IllegalStateException("unsupported index.type");
+      IndexType indexType = IndexModule.getIndexType(dbInjector);
+      if (indexType.isLucene()) {
+        modules.add(new LuceneIndexModuleOnInit());
+      } else if (indexType.isElasticsearch()) {
+        modules.add(new ElasticIndexModuleOnInit());
+      } else {
+        throw new IllegalStateException("unsupported index.type = " + indexType);
       }
       sysInjector = dbInjector.createChildInjector(modules);
     }
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 273ebfb..0333942 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -20,11 +20,11 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerIdProvider;
@@ -155,7 +155,7 @@
 
   private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
     return InternalGroupUpdate.builder()
-        .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.getId())))
+        .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.id())))
         .build();
   }
 
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 27e6ce9..cf208ae 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -18,18 +18,16 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.SequencesOnInit;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -49,7 +47,6 @@
 public class InitAdminUser implements InitStep {
   private final InitFlags flags;
   private final ConsoleUI ui;
-  private final AllUsersNameOnInitProvider allUsers;
   private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
@@ -62,7 +59,6 @@
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
-      AllUsersNameOnInitProvider allUsers,
       AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds,
@@ -70,7 +66,6 @@
       GroupsOnInit groupsOnInit) {
     this.flags = flags;
     this.ui = ui;
-    this.allUsers = allUsers;
     this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
@@ -101,7 +96,7 @@
     if (!accounts.hasAnyAccount()) {
       ui.header("Gerrit Administrator");
       if (ui.yesno(true, "Create administrator user")) {
-        Account.Id id = new Account.Id(sequencesOnInit.nextAccountId());
+        Account.Id id = Account.id(sequencesOnInit.nextAccountId());
         String username = ui.readString("admin", "username");
         String name = ui.readString("Administrator", "name");
         String httpPassword = ui.readString("secret", "HTTP password");
@@ -116,11 +111,9 @@
         }
         externalIds.insert("Add external IDs for initial admin user", extIds);
 
-        Account a = new Account(id, TimeUtil.nowTs());
-        a.setFullName(name);
-        a.setPreferredEmail(email);
-        accounts.insert(a);
-
+        Account persistedAccount =
+            accounts.insert(
+                Account.builder(id, TimeUtil.nowTs()).setFullName(name).setPreferredEmail(email));
         // Only two groups should exist at this point in time and hence iterating over all of them
         // is cheap.
         Optional<GroupReference> adminGroupReference =
@@ -132,7 +125,7 @@
           throw new NoSuchGroupException("Administrators");
         }
         GroupReference adminGroup = adminGroupReference.get();
-        groupsOnInit.addGroupMember(adminGroup.getUUID(), a);
+        groupsOnInit.addGroupMember(adminGroup.getUUID(), persistedAccount);
 
         if (sshKey != null) {
           VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
@@ -140,7 +133,7 @@
           authorizedKeys.save("Add SSH key for initial admin user\n");
         }
 
-        AccountState as = AccountState.forAccount(new AllUsersName(allUsers.get()), a, extIds);
+        AccountState as = AccountState.forAccount(persistedAccount, extIds);
         for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
           accountIndex.replace(as);
         }
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index 0de08f2..83d9261 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -22,7 +23,6 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,27 +53,23 @@
 
   @Override
   public void run() throws IOException {
-    IndexType type = IndexType.LUCENE;
-    if (IndexType.values().length > 1) {
-      ui.header("Index");
-      type = index.select("Type", "type", type);
-    }
+    ui.header("Index");
+    IndexType type =
+        new IndexType(
+            index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
-    if (type == IndexType.ELASTICSEARCH) {
+    if (type.isElasticsearch()) {
       Section elasticsearch = sections.get("elasticsearch", null);
       elasticsearch.string("Index Prefix", "prefix", "gerrit_");
       elasticsearch.string("Server", "server", "http://localhost:9200");
       index.string("Result window size", "maxLimit", "10000");
     }
 
-    if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
+    if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
         IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
-      if (IndexType.values().length <= 1) {
-        ui.header("Index");
-      }
       String message =
           String.format(
               "\nThe index must be %sbuilt before starting Gerrit:\n"
diff --git a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index a9c6cc8..acde91f 100644
--- a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -17,11 +17,11 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.AuthorizedKeys;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index 20e7ba2..5ca239e 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.project.GroupList;
@@ -43,7 +43,7 @@
     super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
     this.baseConfig =
         ProjectConfig.Factory.getBaseConfig(
-            site, new AllProjectsName(allProjects.get()), new Project.NameKey(allProjects.get()));
+            site, new AllProjectsName(allProjects.get()), Project.nameKey(allProjects.get()));
   }
 
   public Config getConfig() {
@@ -71,7 +71,7 @@
 
   private GroupList readGroupList() throws IOException {
     return GroupList.parse(
-        new Project.NameKey(project),
+        Project.nameKey(project),
         readUTF8(GroupList.FILE_NAME),
         error ->
             logger.atSevere().log(
diff --git a/java/com/google/gerrit/pgm/init/api/BUILD b/java/com/google/gerrit/pgm/init/api/BUILD
index 19203fc..693d319 100644
--- a/java/com/google/gerrit/pgm/init/api/BUILD
+++ b/java/com/google/gerrit/pgm/init/api/BUILD
@@ -7,13 +7,12 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/exceptions",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index 2f94bdb..a937c4b 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index 87f7aeb..b5d35f4 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -127,8 +127,10 @@
 
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, boolean nullIfDefault) {
+    @SuppressWarnings("rawtypes")
+    Class<? extends Enum> declaringClass = defValue.getDeclaringClass();
     @SuppressWarnings("unchecked")
-    E allowedValues = (E) EnumSet.allOf(defValue.getClass());
+    E allowedValues = (E) EnumSet.allOf(declaringClass);
     return select(title, name, defValue, allowedValues, nullIfDefault);
   }
 
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index d3d22cb..c11230c 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.RepoSequence;
@@ -38,7 +38,7 @@
         new RepoSequence(
             repoManager,
             GitReferenceUpdated.DISABLED,
-            new Project.NameKey(allUsersName.get()),
+            Project.nameKey(allUsersName.get()),
             Sequences.NAME_ACCOUNTS,
             () -> Sequences.FIRST_ACCOUNT_ID,
             1);
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index 738cafd..f779601 100644
--- a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -58,7 +58,7 @@
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path)) {
-        load(new Project.NameKey(project), repo);
+        load(Project.nameKey(project), repo);
       }
     }
     return this;
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 2663f42..0a41db5 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.pgm.rules;
 
 import com.google.gerrit.common.Version;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index 60fb5e4..1b97971 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -6,11 +6,11 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
@@ -19,9 +19,9 @@
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
     ],
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 8e2a244..ce2b05d 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -19,13 +19,13 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCacheImpl;
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index eed307f..98558fb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -18,6 +18,7 @@
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.gerrit.common.Die;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
@@ -28,6 +29,7 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.inject.AbstractModule;
@@ -106,6 +108,13 @@
           });
     }
 
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            listener().to(SystemReaderInstaller.class);
+          }
+        });
     Module configModule = new GerritServerConfigModule();
     modules.add(configModule);
     modules.add(
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 76afbe7..7c1241a 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -5,8 +5,7 @@
     srcs = glob(["common/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
index acfa8f0..0e5f887 100644
--- a/java/com/google/gerrit/proto/testing/BUILD
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -7,7 +7,6 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib/commons:lang3",
         "//lib/truth",
diff --git a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
index 546ff89..79affc6 100644
--- a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -32,7 +32,7 @@
  * Subject about classes that are serialized into persistent caches or indices.
  *
  * <p>Hand-written {@link com.google.gerrit.server.cache.serialize.CacheSerializer CacheSerializer}
- * and {@link com.google.gerrit.reviewdb.converter.ProtoConverter ProtoConverter} implementations
+ * and {@link com.google.gerrit.entities.converter.ProtoConverter ProtoConverter} implementations
  * depend on the exact representation of the data stored in a class, so it is important to verify
  * any assumptions about the structure of the serialized classes. This class contains assertions
  * about serialized classes, and should be used for every class that has a custom serializer
@@ -48,7 +48,7 @@
  * the hand-written serializer. Usually, serializer implementations should be written in such a way
  * that new fields are considered optional, and won't require bumping the version.
  */
-public class SerializedClassSubject extends Subject<SerializedClassSubject, Class<?>> {
+public class SerializedClassSubject extends Subject {
   public static SerializedClassSubject assertThatSerializedClass(Class<?> actual) {
     // This formulation fails in Eclipse 4.7.3a with "The type
     // SerializedClassSubject does not define SerializedClassSubject() that is
@@ -60,20 +60,23 @@
     return assertAbout(factory).that(actual);
   }
 
-  private SerializedClassSubject(FailureMetadata metadata, Class<?> actual) {
-    super(metadata, actual);
+  private final Class<?> clazz;
+
+  private SerializedClassSubject(FailureMetadata metadata, Class<?> clazz) {
+    super(metadata, clazz);
+    this.clazz = clazz;
   }
 
   public void isAbstract() {
     isNotNull();
-    if (!Modifier.isAbstract(actual().getModifiers())) {
+    if (!Modifier.isAbstract(clazz.getModifiers())) {
       failWithActual(simpleFact("expected class to be abstract"));
     }
   }
 
   public void isConcrete() {
     isNotNull();
-    if (Modifier.isAbstract(actual().getModifiers())) {
+    if (Modifier.isAbstract(clazz.getModifiers())) {
       failWithActual(simpleFact("expected class to be concrete"));
     }
   }
@@ -82,7 +85,7 @@
     isConcrete();
     check("fields()")
         .that(
-            FieldUtils.getAllFieldsList(actual()).stream()
+            FieldUtils.getAllFieldsList(clazz).stream()
                 .filter(f -> !Modifier.isStatic(f.getModifiers()))
                 .collect(toImmutableMap(Field::getName, Field::getGenericType)))
         .containsExactlyEntriesIn(expectedFields);
@@ -91,9 +94,9 @@
   public void hasAutoValueMethods(Map<String, Type> expectedMethods) {
     // Would be nice if we could check clazz is an @AutoValue, but the retention is not RUNTIME.
     isAbstract();
-    check("noArgumentAbstractMethodsOn(%s)", actual().getName())
+    check("noArgumentAbstractMethods()")
         .that(
-            Arrays.stream(actual().getDeclaredMethods())
+            Arrays.stream(clazz.getDeclaredMethods())
                 .filter(m -> !Modifier.isStatic(m.getModifiers()))
                 .filter(m -> Modifier.isAbstract(m.getModifiers()))
                 .filter(m -> m.getParameters().length == 0)
@@ -103,8 +106,6 @@
 
   public void extendsClass(Type superclassType) {
     isNotNull();
-    check("superclass(%s)", actual().getName())
-        .that(actual().getGenericSuperclass())
-        .isEqualTo(superclassType);
+    check("getGenericSuperclass()").that(clazz.getGenericSuperclass()).isEqualTo(superclassType);
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
deleted file mode 100644
index 578865c..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link AccountGroup} in an {@link AccountGroup}. */
-public final class AccountGroupById {
-  public static Key key(AccountGroup.Id groupId, AccountGroup.UUID includeUuid) {
-    return new Key(groupId, includeUuid);
-  }
-
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected AccountGroup.Id groupId;
-
-    protected AccountGroup.UUID includeUUID;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
-      groupId = g;
-      includeUUID = u;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public AccountGroup.UUID includeUuid() {
-      return getIncludeUUID();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-  }
-
-  protected Key key;
-
-  protected AccountGroupById() {}
-
-  public AccountGroupById(AccountGroupById.Key k) {
-    key = k;
-  }
-
-  public AccountGroupById.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.groupId;
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.includeUUID;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupById) && Objects.equals(key, ((AccountGroupById) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
deleted file mode 100644
index 308e1e1..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-import java.util.Objects;
-
-/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
-public final class AccountGroupByIdAud {
-  public static Key key(AccountGroup.Id groupId, AccountGroup.UUID includeUuid, Timestamp addedOn) {
-    return new Key(groupId, includeUuid, addedOn);
-  }
-
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected AccountGroup.Id groupId;
-
-    protected AccountGroup.UUID includeUUID;
-
-    protected Timestamp addedOn;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
-      groupId = g;
-      includeUUID = u;
-      addedOn = t;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public AccountGroup.UUID includeUuid() {
-      return getIncludeUUID();
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    public Timestamp addedOn() {
-      return getAddedOn();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", includeUUID="
-          + includeUUID
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
-  }
-
-  protected Key key;
-
-  protected Account.Id addedBy;
-
-  @Nullable protected Account.Id removedBy;
-
-  @Nullable protected Timestamp removedOn;
-
-  protected AccountGroupByIdAud() {}
-
-  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
-    final AccountGroup.Id group = m.getGroupId();
-    final AccountGroup.UUID include = m.getIncludeUUID();
-    key = new AccountGroupByIdAud.Key(group, include, when);
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud(AccountGroupByIdAud.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.getParentKey();
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.getIncludeUUID();
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupByIdAud)) {
-      return false;
-    }
-    AccountGroupByIdAud a = (AccountGroupByIdAud) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
deleted file mode 100644
index dfa7d24..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMember {
-  public static Key key(Account.Id accountId, AccountGroup.Id groupId) {
-    return new Key(accountId, groupId);
-  }
-
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected Account.Id accountId;
-
-    protected AccountGroup.Id groupId;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g) {
-      accountId = a;
-      groupId = g;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.Id getAccountGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getAccountGroupId();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-  }
-
-  protected Key key;
-
-  protected AccountGroupMember() {}
-
-  public AccountGroupMember(AccountGroupMember.Key k) {
-    key = k;
-  }
-
-  public AccountGroupMember.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public AccountGroup.Id getAccountGroupId() {
-    return key.groupId;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupMember) && Objects.equals(key, ((AccountGroupMember) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
deleted file mode 100644
index 5d43b4a..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// 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.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-import java.util.Objects;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMemberAudit {
-  public static Key key(Account.Id accountId, AccountGroup.Id groupId, Timestamp addedOn) {
-    return new Key(accountId, groupId, addedOn);
-  }
-
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected Account.Id accountId;
-
-    protected AccountGroup.Id groupId;
-
-    protected Timestamp addedOn;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
-      accountId = a;
-      groupId = g;
-      addedOn = t;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getGroupId();
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    public Timestamp addedOn() {
-      return getAddedOn();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", accountId="
-          + accountId
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
-  }
-
-  protected Key key;
-
-  protected Account.Id addedBy;
-
-  @Nullable protected Account.Id removedBy;
-
-  @Nullable protected Timestamp removedOn;
-
-  protected AccountGroupMemberAudit() {}
-
-  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
-    final Account.Id who = m.getAccountId();
-    final AccountGroup.Id group = m.getAccountGroupId();
-    key = new AccountGroupMemberAudit.Key(who, group, addedOn);
-    addedBy = adder;
-  }
-
-  public AccountGroupMemberAudit(AccountGroupMemberAudit.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
-
-  public AccountGroupMemberAudit.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.getGroupId();
-  }
-
-  public Account.Id getMemberId() {
-    return key.getParentKey();
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public void removedLegacy() {
-    removedBy = addedBy;
-    removedOn = key.addedOn;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupMemberAudit)) {
-      return false;
-    }
-    AccountGroupMemberAudit a = (AccountGroupMemberAudit) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/Branch.java b/java/com/google/gerrit/reviewdb/client/Branch.java
deleted file mode 100644
index 4ea49b7..0000000
--- a/java/com/google/gerrit/reviewdb/client/Branch.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.StringKey;
-
-/** Line of development within a {@link Project}. */
-public final class Branch {
-  public static NameKey nameKey(Project.NameKey projectName, String branchName) {
-    return new NameKey(projectName, RefNames.fullName(branchName));
-  }
-
-  public static NameKey nameKey(String projectName, String branchName) {
-    return nameKey(Project.nameKey(projectName), branchName);
-  }
-
-  /** Branch name key */
-  public static class NameKey extends StringKey<Project.NameKey> {
-    private static final long serialVersionUID = 1L;
-
-    protected Project.NameKey projectName;
-
-    protected String branchName;
-
-    protected NameKey() {
-      projectName = new Project.NameKey();
-    }
-
-    public NameKey(Project.NameKey proj, String branchName) {
-      projectName = proj;
-      set(branchName);
-    }
-
-    public NameKey(String proj, String branchName) {
-      this(new Project.NameKey(proj), branchName);
-    }
-
-    @Override
-    public String get() {
-      return branchName;
-    }
-
-    public String branch() {
-      return get();
-    }
-
-    @Override
-    protected void set(String newValue) {
-      branchName = RefNames.fullName(newValue);
-    }
-
-    @Override
-    public Project.NameKey getParentKey() {
-      return projectName;
-    }
-
-    public Project.NameKey project() {
-      return getParentKey();
-    }
-
-    public String getShortName() {
-      return RefNames.shortName(get());
-    }
-  }
-
-  protected NameKey name;
-  protected RevId revision;
-  protected boolean canDelete;
-
-  protected Branch() {}
-
-  public Branch(Branch.NameKey newName) {
-    name = newName;
-  }
-
-  public Branch.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name.get();
-  }
-
-  public String getShortName() {
-    return name.getShortName();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId id) {
-    revision = id;
-  }
-
-  public boolean getCanDelete() {
-    return canDelete;
-  }
-
-  public void setCanDelete(boolean canDelete) {
-    this.canDelete = canDelete;
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
deleted file mode 100644
index ce218c0..0000000
--- a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ /dev/null
@@ -1,361 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gwtorm.client.StringKey;
-import java.sql.Timestamp;
-import java.util.Objects;
-
-/**
- * A comment left by a user on a specific line of a {@link Patch}.
- *
- * <p>New APIs should not expose this class.
- *
- * @see Comment
- */
-public final class PatchLineComment {
-  public static class Key extends StringKey<Patch.Key> {
-    private static final long serialVersionUID = 1L;
-
-    public static Key from(Change.Id changeId, Comment.Key key) {
-      return new Key(
-          new Patch.Key(new PatchSet.Id(changeId, key.patchSetId), key.filename), key.uuid);
-    }
-
-    protected Patch.Key patchKey;
-
-    protected String uuid;
-
-    protected Key() {
-      patchKey = new Patch.Key();
-    }
-
-    public Key(Patch.Key p, String uuid) {
-      this.patchKey = p;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Patch.Key getParentKey() {
-      return patchKey;
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    @Override
-    public void set(String newValue) {
-      uuid = newValue;
-    }
-
-    public Comment.Key asCommentKey() {
-      return new Comment.Key(
-          get(), getParentKey().getFileName(), getParentKey().getParentKey().get());
-    }
-  }
-
-  public static final char STATUS_DRAFT = 'd';
-  public static final char STATUS_PUBLISHED = 'P';
-
-  public enum Status {
-    DRAFT(STATUS_DRAFT),
-
-    PUBLISHED(STATUS_PUBLISHED);
-
-    private final char code;
-
-    Status(char c) {
-      code = c;
-    }
-
-    public char getCode() {
-      return code;
-    }
-
-    public static Status forCode(char c) {
-      for (Status s : Status.values()) {
-        if (s.code == c) {
-          return s;
-        }
-      }
-      return null;
-    }
-  }
-
-  public static PatchLineComment from(
-      Change.Id changeId, PatchLineComment.Status status, Comment c) {
-    PatchLineComment.Key key =
-        new PatchLineComment.Key(
-            new Patch.Key(new PatchSet.Id(changeId, c.key.patchSetId), c.key.filename), c.key.uuid);
-
-    PatchLineComment plc =
-        new PatchLineComment(key, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
-    plc.setSide(c.side);
-    plc.setMessage(c.message);
-    if (c.range != null) {
-      Comment.Range r = c.range;
-      plc.setRange(new CommentRange(r.startLine, r.startChar, r.endLine, r.endChar));
-    }
-    plc.setTag(c.tag);
-    plc.setRevId(new RevId(c.revId));
-    plc.setStatus(status);
-    plc.setRealAuthor(c.getRealAuthor().getId());
-    plc.setUnresolved(c.unresolved);
-    return plc;
-  }
-
-  protected Key key;
-
-  /** Line number this comment applies to; it should display after the line. */
-  protected int lineNbr;
-
-  /** Who wrote this comment. */
-  protected Account.Id author;
-
-  /** When this comment was drafted. */
-  protected Timestamp writtenOn;
-
-  /** Current publication state of the comment; see {@link Status}. */
-  protected char status;
-
-  /** Which file is this comment; 0 is ancestor, 1 is new version. */
-  protected short side;
-
-  /** The text left by the user. */
-  @Nullable protected String message;
-
-  /** The parent of this comment, or null if this is the first comment on this line */
-  @Nullable protected String parentUuid;
-
-  @Nullable protected CommentRange range;
-
-  @Nullable protected String tag;
-
-  /** Real user that added this comment on behalf of the user recorded in {@link #author}. */
-  @Nullable protected Account.Id realAuthor;
-
-  /** True if this comment requires further action. */
-  protected boolean unresolved;
-
-  /** The RevId for the commit to which this comment is referring. */
-  protected RevId revId;
-
-  protected PatchLineComment() {}
-
-  public PatchLineComment(
-      PatchLineComment.Key id, int line, Account.Id a, String parentUuid, Timestamp when) {
-    key = id;
-    lineNbr = line;
-    author = a;
-    setParentUuid(parentUuid);
-    setStatus(Status.DRAFT);
-    setWrittenOn(when);
-  }
-
-  public PatchLineComment(PatchLineComment o) {
-    key = o.key;
-    lineNbr = o.lineNbr;
-    author = o.author;
-    realAuthor = o.realAuthor;
-    writtenOn = o.writtenOn;
-    status = o.status;
-    side = o.side;
-    message = o.message;
-    parentUuid = o.parentUuid;
-    revId = o.revId;
-    if (o.range != null) {
-      range =
-          new CommentRange(
-              o.range.getStartLine(),
-              o.range.getStartCharacter(),
-              o.range.getEndLine(),
-              o.range.getEndCharacter());
-    }
-  }
-
-  public PatchLineComment.Key getKey() {
-    return key;
-  }
-
-  public PatchSet.Id getPatchSetId() {
-    return key.getParentKey().getParentKey();
-  }
-
-  public int getLine() {
-    return lineNbr;
-  }
-
-  public void setLine(int line) {
-    lineNbr = line;
-  }
-
-  public Account.Id getAuthor() {
-    return author;
-  }
-
-  public Account.Id getRealAuthor() {
-    return realAuthor != null ? realAuthor : getAuthor();
-  }
-
-  public void setRealAuthor(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
-  }
-
-  public Timestamp getWrittenOn() {
-    return writtenOn;
-  }
-
-  public Status getStatus() {
-    return Status.forCode(status);
-  }
-
-  public void setStatus(Status s) {
-    status = s.getCode();
-  }
-
-  public short getSide() {
-    return side;
-  }
-
-  public void setSide(short s) {
-    side = s;
-  }
-
-  public String getMessage() {
-    return message;
-  }
-
-  public void setMessage(String s) {
-    message = s;
-  }
-
-  public void setWrittenOn(Timestamp ts) {
-    writtenOn = ts;
-  }
-
-  public String getParentUuid() {
-    return parentUuid;
-  }
-
-  public void setParentUuid(String inReplyTo) {
-    parentUuid = inReplyTo;
-  }
-
-  public void setRange(Range r) {
-    if (r != null) {
-      range =
-          new CommentRange(
-              r.startLine, r.startCharacter,
-              r.endLine, r.endCharacter);
-    } else {
-      range = null;
-    }
-  }
-
-  public void setRange(CommentRange r) {
-    range = r;
-  }
-
-  public CommentRange getRange() {
-    return range;
-  }
-
-  public void setRevId(RevId rev) {
-    revId = rev;
-  }
-
-  public RevId getRevId() {
-    return revId;
-  }
-
-  public void setTag(String tag) {
-    this.tag = tag;
-  }
-
-  public String getTag() {
-    return tag;
-  }
-
-  public void setUnresolved(Boolean unresolved) {
-    this.unresolved = unresolved;
-  }
-
-  public Boolean getUnresolved() {
-    return unresolved;
-  }
-
-  public Comment asComment(String serverId) {
-    Comment c =
-        new Comment(key.asCommentKey(), author, writtenOn, side, message, serverId, unresolved);
-    c.setRevId(revId);
-    c.setRange(range);
-    c.lineNbr = lineNbr;
-    c.parentUuid = parentUuid;
-    c.tag = tag;
-    c.setRealAuthor(getRealAuthor());
-    return c;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof PatchLineComment) {
-      PatchLineComment c = (PatchLineComment) o;
-      return Objects.equals(key, c.getKey())
-          && Objects.equals(lineNbr, c.getLine())
-          && Objects.equals(author, c.getAuthor())
-          && Objects.equals(writtenOn, c.getWrittenOn())
-          && Objects.equals(status, c.getStatus().getCode())
-          && Objects.equals(side, c.getSide())
-          && Objects.equals(message, c.getMessage())
-          && Objects.equals(parentUuid, c.getParentUuid())
-          && Objects.equals(range, c.getRange())
-          && Objects.equals(revId, c.getRevId())
-          && Objects.equals(tag, c.getTag())
-          && Objects.equals(unresolved, c.getUnresolved());
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder builder = new StringBuilder();
-    builder.append("PatchLineComment{");
-    builder.append("key=").append(key).append(',');
-    builder.append("lineNbr=").append(lineNbr).append(',');
-    builder.append("author=").append(author.get()).append(',');
-    builder.append("realAuthor=").append(realAuthor != null ? realAuthor.get() : "").append(',');
-    builder.append("writtenOn=").append(writtenOn.toString()).append(',');
-    builder.append("status=").append(status).append(',');
-    builder.append("side=").append(side).append(',');
-    builder.append("message=").append(Objects.toString(message, "")).append(',');
-    builder.append("parentUuid=").append(Objects.toString(parentUuid, "")).append(',');
-    builder.append("range=").append(Objects.toString(range, "")).append(',');
-    builder.append("revId=").append(revId != null ? revId.get() : "").append(',');
-    builder.append("tag=").append(Objects.toString(tag, "")).append(',');
-    builder.append("unresolved=").append(unresolved);
-    builder.append('}');
-    return builder.toString();
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
deleted file mode 100644
index 684f092..0000000
--- a/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ /dev/null
@@ -1,304 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.IntKey;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-
-/** A single revision of a {@link Change}. */
-public final class PatchSet {
-  /** Is the reference name a change reference? */
-  public static boolean isChangeRef(String name) {
-    return Id.fromRef(name) != null;
-  }
-
-  /**
-   * Is the reference name a change reference?
-   *
-   * @deprecated use isChangeRef instead.
-   */
-  @Deprecated
-  public static boolean isRef(String name) {
-    return isChangeRef(name);
-  }
-
-  public static String joinGroups(List<String> groups) {
-    if (groups == null) {
-      throw new IllegalArgumentException("groups may not be null");
-    }
-    StringBuilder sb = new StringBuilder();
-    boolean first = true;
-    for (String g : groups) {
-      if (!first) {
-        sb.append(',');
-      } else {
-        first = false;
-      }
-      sb.append(g);
-    }
-    return sb.toString();
-  }
-
-  public static List<String> splitGroups(String joinedGroups) {
-    if (joinedGroups == null) {
-      throw new IllegalArgumentException("groups may not be null");
-    }
-    List<String> groups = new ArrayList<>();
-    int i = 0;
-    while (true) {
-      int idx = joinedGroups.indexOf(',', i);
-      if (idx < 0) {
-        groups.add(joinedGroups.substring(i));
-        break;
-      }
-      groups.add(joinedGroups.substring(i, idx));
-      i = idx + 1;
-    }
-    return groups;
-  }
-
-  public static Id id(Change.Id changeId, int id) {
-    return new Id(changeId, id);
-  }
-
-  public static class Id extends IntKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
-
-    public Change.Id changeId;
-
-    public int patchSetId;
-
-    public Id() {
-      changeId = new Change.Id();
-    }
-
-    public Id(Change.Id change, int id) {
-      this.changeId = change;
-      this.patchSetId = id;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    public Change.Id changeId() {
-      return getParentKey();
-    }
-
-    @Override
-    public int get() {
-      return patchSetId;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      patchSetId = newValue;
-    }
-
-    public String toRefName() {
-      return changeId.refPrefixBuilder().append(patchSetId).toString();
-    }
-
-    /** Parse a PatchSet.Id out of a string representation. */
-    public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
-    }
-
-    /** Parse a PatchSet.Id from a {@link PatchSet#getRefName()} result. */
-    public static Id fromRef(String ref) {
-      int cs = Change.Id.startIndex(ref);
-      if (cs < 0) {
-        return null;
-      }
-      int ce = Change.Id.nextNonDigit(ref, cs);
-      int patchSetId = fromRef(ref, ce);
-      if (patchSetId < 0) {
-        return null;
-      }
-      int changeId = Integer.parseInt(ref.substring(cs, ce));
-      return new PatchSet.Id(new Change.Id(changeId), patchSetId);
-    }
-
-    static int fromRef(String ref, int changeIdEnd) {
-      // Patch set ID.
-      int ps = changeIdEnd + 1;
-      if (ps >= ref.length() || ref.charAt(ps) == '0') {
-        return -1;
-      }
-      for (int i = ps; i < ref.length(); i++) {
-        if (ref.charAt(i) < '0' || ref.charAt(i) > '9') {
-          return -1;
-        }
-      }
-      return Integer.parseInt(ref.substring(ps));
-    }
-
-    public String getId() {
-      return toId(patchSetId);
-    }
-
-    public static String toId(int number) {
-      return number == 0 ? "edit" : String.valueOf(number);
-    }
-  }
-
-  protected Id id;
-
-  @Nullable protected RevId revision;
-
-  protected Account.Id uploader;
-
-  /** When this patch set was first introduced onto the change. */
-  protected Timestamp createdOn;
-
-  /**
-   * Opaque group identifier, usually assigned during creation.
-   *
-   * <p>This field is actually a comma-separated list of values, as in rare cases involving merge
-   * commits a patch set may belong to multiple groups.
-   *
-   * <p>Changes on the same branch having patch sets with intersecting groups are considered
-   * related, as in the "Related Changes" tab.
-   */
-  @Nullable protected String groups;
-
-  // DELETED id = 7 (pushCertficate)
-
-  /** Certificate sent with a push that created this patch set. */
-  @Nullable protected String pushCertificate;
-
-  /**
-   * Optional user-supplied description for this patch set.
-   *
-   * <p>When this field is null, the description was never set on the patch set. When this field is
-   * an empty string, the description was set and later cleared.
-   */
-  @Nullable protected String description;
-
-  protected PatchSet() {}
-
-  public PatchSet(PatchSet.Id k) {
-    id = k;
-  }
-
-  public PatchSet(PatchSet src) {
-    this.id = src.id;
-    this.revision = src.revision;
-    this.uploader = src.uploader;
-    this.createdOn = src.createdOn;
-    this.groups = src.groups;
-    this.pushCertificate = src.pushCertificate;
-    this.description = src.description;
-  }
-
-  public PatchSet.Id getId() {
-    return id;
-  }
-
-  public int getPatchSetId() {
-    return id.get();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId i) {
-    revision = i;
-  }
-
-  public Account.Id getUploader() {
-    return uploader;
-  }
-
-  public void setUploader(Account.Id who) {
-    uploader = who;
-  }
-
-  public Timestamp getCreatedOn() {
-    return createdOn;
-  }
-
-  public void setCreatedOn(Timestamp ts) {
-    createdOn = ts;
-  }
-
-  public List<String> getGroups() {
-    if (groups == null) {
-      return Collections.emptyList();
-    }
-    return splitGroups(groups);
-  }
-
-  public void setGroups(List<String> groups) {
-    if (groups == null) {
-      groups = Collections.emptyList();
-    }
-    this.groups = joinGroups(groups);
-  }
-
-  public String getRefName() {
-    return id.toRefName();
-  }
-
-  public String getPushCertificate() {
-    return pushCertificate;
-  }
-
-  public void setPushCertificate(String cert) {
-    pushCertificate = cert;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String description) {
-    this.description = description;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof PatchSet)) {
-      return false;
-    }
-    PatchSet p = (PatchSet) o;
-    return Objects.equals(id, p.id)
-        && Objects.equals(revision, p.revision)
-        && Objects.equals(uploader, p.uploader)
-        && Objects.equals(createdOn, p.createdOn)
-        && Objects.equals(groups, p.groups)
-        && Objects.equals(pushCertificate, p.pushCertificate)
-        && Objects.equals(description, p.description);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(id, revision, uploader, createdOn, groups, pushCertificate, description);
-  }
-
-  @Override
-  public String toString() {
-    return "[PatchSet " + getId().toString() + "]";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
deleted file mode 100644
index e1c4ea9..0000000
--- a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ /dev/null
@@ -1,238 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-import java.util.Date;
-import java.util.Objects;
-
-/** An approval (or negative approval) on a patch set. */
-public final class PatchSetApproval {
-  public static Key key(PatchSet.Id patchSetId, Account.Id accountId, LabelId labelId) {
-    return new Key(patchSetId, accountId, labelId);
-  }
-
-  public static class Key extends CompoundKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected PatchSet.Id patchSetId;
-
-    protected Account.Id accountId;
-
-    protected LabelId categoryId;
-
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-      accountId = new Account.Id();
-      categoryId = new LabelId();
-    }
-
-    public Key(PatchSet.Id ps, Account.Id a, LabelId c) {
-      this.patchSetId = ps;
-      this.accountId = a;
-      this.categoryId = c;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    public PatchSet.Id patchSetId() {
-      return getParentKey();
-    }
-
-    public Account.Id getAccountId() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getAccountId();
-    }
-
-    public LabelId getLabelId() {
-      return categoryId;
-    }
-
-    public LabelId labelId() {
-      return getLabelId();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {accountId, categoryId};
-    }
-  }
-
-  protected Key key;
-
-  /**
-   * Value assigned by the user.
-   *
-   * <p>The precise meaning of "value" is up to each category.
-   *
-   * <p>In general:
-   *
-   * <ul>
-   *   <li><b>&lt; 0:</b> The approval is rejected/revoked.
-   *   <li><b>= 0:</b> No indication either way is provided.
-   *   <li><b>&gt; 0:</b> The approval is approved/positive.
-   * </ul>
-   *
-   * and in the negative and positive direction a magnitude can be assumed.The further from 0 the
-   * more assertive the approval.
-   */
-  protected short value;
-
-  protected Timestamp granted;
-
-  @Nullable protected String tag;
-
-  /** Real user that made this approval on behalf of the user recorded in {@link Key#accountId}. */
-  @Nullable protected Account.Id realAccountId;
-
-  protected boolean postSubmit;
-
-  // DELETED: id = 4 (changeOpen)
-  // DELETED: id = 5 (changeSortKey)
-
-  protected PatchSetApproval() {}
-
-  public PatchSetApproval(PatchSetApproval.Key k, short v, Date ts) {
-    key = k;
-    setValue(v);
-    setGranted(ts);
-  }
-
-  public PatchSetApproval(PatchSet.Id psId, PatchSetApproval src) {
-    key = new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
-    value = src.getValue();
-    granted = src.granted;
-    realAccountId = src.realAccountId;
-    tag = src.tag;
-    postSubmit = src.postSubmit;
-  }
-
-  public PatchSetApproval(PatchSetApproval src) {
-    this(src.getPatchSetId(), src);
-  }
-
-  public PatchSetApproval.Key getKey() {
-    return key;
-  }
-
-  public PatchSet.Id getPatchSetId() {
-    return key.patchSetId;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public Account.Id getRealAccountId() {
-    return realAccountId != null ? realAccountId : getAccountId();
-  }
-
-  public void setRealAccountId(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAccountId = Objects.equals(getAccountId(), id) ? null : id;
-  }
-
-  public LabelId getLabelId() {
-    return key.categoryId;
-  }
-
-  public short getValue() {
-    return value;
-  }
-
-  public void setValue(short v) {
-    value = v;
-  }
-
-  public Timestamp getGranted() {
-    return granted;
-  }
-
-  public void setGranted(Date when) {
-    if (when instanceof Timestamp) {
-      granted = (Timestamp) when;
-    } else {
-      granted = new Timestamp(when.getTime());
-    }
-  }
-
-  public void setTag(String t) {
-    tag = t;
-  }
-
-  public String getLabel() {
-    return getLabelId().get();
-  }
-
-  public boolean isLegacySubmit() {
-    return LabelId.LEGACY_SUBMIT_NAME.equals(getLabel());
-  }
-
-  public String getTag() {
-    return tag;
-  }
-
-  public void setPostSubmit(boolean postSubmit) {
-    this.postSubmit = postSubmit;
-  }
-
-  public boolean isPostSubmit() {
-    return postSubmit;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb =
-        new StringBuilder("[")
-            .append(key)
-            .append(": ")
-            .append(value)
-            .append(",tag:")
-            .append(tag)
-            .append(",realAccountId:")
-            .append(realAccountId);
-    if (postSubmit) {
-      sb.append(",postSubmit");
-    }
-    return sb.append(']').toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof PatchSetApproval) {
-      PatchSetApproval p = (PatchSetApproval) o;
-      return Objects.equals(key, p.key)
-          && Objects.equals(value, p.value)
-          && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag)
-          && Objects.equals(realAccountId, p.realAccountId)
-          && postSubmit == p.postSubmit;
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, value, granted, tag, realAccountId, postSubmit);
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/RevId.java b/java/com/google/gerrit/reviewdb/client/RevId.java
deleted file mode 100644
index 99b6c2c..0000000
--- a/java/com/google/gerrit/reviewdb/client/RevId.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-/** A revision identifier for a file or a change. */
-public final class RevId {
-  public static final int ABBREV_LEN = 7;
-  public static final int LEN = 40;
-
-  protected String id;
-
-  protected RevId() {}
-
-  public RevId(String str) {
-    id = str;
-  }
-
-  /** @return the value of this revision id. */
-  public String get() {
-    return id;
-  }
-
-  /** @return true if this revision id has all required digits. */
-  public boolean isComplete() {
-    return get().length() == LEN;
-  }
-
-  /**
-   * @return if {@link #isComplete()}, {@code this}; otherwise a new RevId with 'z' appended on the
-   *     end.
-   */
-  public RevId max() {
-    if (isComplete()) {
-      return this;
-    }
-
-    final StringBuilder revEnd = new StringBuilder(get().length() + 1);
-    revEnd.append(get());
-    revEnd.append('z');
-    return new RevId(revEnd.toString());
-  }
-
-  @Override
-  public int hashCode() {
-    return id.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof RevId) && id.equals(((RevId) o).id);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{" + id + "}";
-  }
-
-  public boolean matches(String str) {
-    return id.startsWith(str.toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/RobotComment.java b/java/com/google/gerrit/reviewdb/client/RobotComment.java
deleted file mode 100644
index eceb0bf..0000000
--- a/java/com/google/gerrit/reviewdb/client/RobotComment.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// 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.
-
-package com.google.gerrit.reviewdb.client;
-
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-
-public class RobotComment extends Comment {
-  public String robotId;
-  public String robotRunId;
-  public String url;
-  public Map<String, String> properties;
-  public List<FixSuggestion> fixSuggestions;
-
-  public RobotComment(
-      Key key,
-      Account.Id author,
-      Timestamp writtenOn,
-      short side,
-      String message,
-      String serverId,
-      String robotId,
-      String robotRunId) {
-    super(key, author, writtenOn, side, message, serverId, false);
-    this.robotId = robotId;
-    this.robotRunId = robotRunId;
-  }
-
-  @Override
-  public String toString() {
-    return new StringBuilder()
-        .append("RobotComment{")
-        .append("key=")
-        .append(key)
-        .append(',')
-        .append("robotId=")
-        .append(robotId)
-        .append(',')
-        .append("robotRunId=")
-        .append(robotRunId)
-        .append(',')
-        .append("lineNbr=")
-        .append(lineNbr)
-        .append(',')
-        .append("author=")
-        .append(author.getId().get())
-        .append(',')
-        .append("realAuthor=")
-        .append(realAuthor != null ? realAuthor.getId().get() : "")
-        .append(',')
-        .append("writtenOn=")
-        .append(writtenOn.toString())
-        .append(',')
-        .append("side=")
-        .append(side)
-        .append(',')
-        .append("message=")
-        .append(Objects.toString(message, ""))
-        .append(',')
-        .append("parentUuid=")
-        .append(Objects.toString(parentUuid, ""))
-        .append(',')
-        .append("range=")
-        .append(Objects.toString(range, ""))
-        .append(',')
-        .append("revId=")
-        .append(revId != null ? revId : "")
-        .append(',')
-        .append("tag=")
-        .append(Objects.toString(tag, ""))
-        .append(',')
-        .append("unresolved=")
-        .append(unresolved)
-        .append(',')
-        .append("url=")
-        .append(url)
-        .append(',')
-        .append("properties=")
-        .append(properties != null ? properties : "")
-        .append("fixSuggestions=")
-        .append(fixSuggestions != null ? fixSuggestions : "")
-        .append('}')
-        .toString();
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
deleted file mode 100644
index b297dfb..0000000
--- a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.StringKey;
-
-/**
- * Defining a project/branch subscription to a project/branch project.
- *
- * <p>This means a class instance represents a repo/branch subscription to a project/branch (the
- * subscriber).
- *
- * <p>A subscriber operates a submodule in defined path.
- */
-public final class SubmoduleSubscription {
-  /** Subscription key */
-  public static class Key extends StringKey<Branch.NameKey> {
-    private static final long serialVersionUID = 1L;
-
-    /**
-     * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
-     * submodules.
-     */
-    protected Branch.NameKey superProject;
-
-    protected String submodulePath;
-
-    protected Key() {
-      superProject = new Branch.NameKey();
-    }
-
-    protected Key(Branch.NameKey superProject, String path) {
-      this.superProject = superProject;
-      this.submodulePath = path;
-    }
-
-    @Override
-    public Branch.NameKey getParentKey() {
-      return superProject;
-    }
-
-    @Override
-    public String get() {
-      return submodulePath;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      this.submodulePath = newValue;
-    }
-  }
-
-  protected Key key;
-
-  protected Branch.NameKey submodule;
-
-  protected SubmoduleSubscription() {}
-
-  public SubmoduleSubscription(Branch.NameKey superProject, Branch.NameKey submodule, String path) {
-    this.key = new Key(superProject, path);
-    this.submodule = submodule;
-  }
-
-  public Key getKey() {
-    return key;
-  }
-
-  public Branch.NameKey getSuperProject() {
-    return key.superProject;
-  }
-
-  public String getPath() {
-    return key.get();
-  }
-
-  public Branch.NameKey getSubmodule() {
-    return submodule;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof SubmoduleSubscription) {
-      return key.equals(((SubmoduleSubscription) o).key)
-          && submodule.equals(((SubmoduleSubscription) o).submodule);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(getSuperProject()).append(':').append(getPath());
-    sb.append(" follows ");
-    sb.append(getSubmodule());
-    return sb.toString();
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
deleted file mode 100644
index 75ee800..0000000
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.converter;
-
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-import java.sql.Timestamp;
-import java.util.List;
-
-public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
-  INSTANCE;
-
-  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
-      PatchSetIdProtoConverter.INSTANCE;
-  private final ProtoConverter<Entities.RevId, RevId> revIdConverter = RevIdProtoConverter.INSTANCE;
-  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
-      AccountIdProtoConverter.INSTANCE;
-
-  @Override
-  public Entities.PatchSet toProto(PatchSet patchSet) {
-    Entities.PatchSet.Builder builder =
-        Entities.PatchSet.newBuilder().setId(patchSetIdConverter.toProto(patchSet.getId()));
-    RevId revision = patchSet.getRevision();
-    if (revision != null) {
-      builder.setRevision(revIdConverter.toProto(revision));
-    }
-    Account.Id uploader = patchSet.getUploader();
-    if (uploader != null) {
-      builder.setUploaderAccountId(accountIdConverter.toProto(uploader));
-    }
-    Timestamp createdOn = patchSet.getCreatedOn();
-    if (createdOn != null) {
-      builder.setCreatedOn(createdOn.getTime());
-    }
-    List<String> groups = patchSet.getGroups();
-    if (!groups.isEmpty()) {
-      builder.setGroups(PatchSet.joinGroups(groups));
-    }
-    String pushCertificate = patchSet.getPushCertificate();
-    if (pushCertificate != null) {
-      builder.setPushCertificate(pushCertificate);
-    }
-    String description = patchSet.getDescription();
-    if (description != null) {
-      builder.setDescription(description);
-    }
-    return builder.build();
-  }
-
-  @Override
-  public PatchSet fromProto(Entities.PatchSet proto) {
-    PatchSet patchSet = new PatchSet(patchSetIdConverter.fromProto(proto.getId()));
-    if (proto.hasRevision()) {
-      patchSet.setRevision(revIdConverter.fromProto(proto.getRevision()));
-    }
-    if (proto.hasUploaderAccountId()) {
-      patchSet.setUploader(accountIdConverter.fromProto(proto.getUploaderAccountId()));
-    }
-    if (proto.hasCreatedOn()) {
-      patchSet.setCreatedOn(new Timestamp(proto.getCreatedOn()));
-    }
-    if (proto.hasGroups()) {
-      patchSet.setGroups(PatchSet.splitGroups(proto.getGroups()));
-    }
-    if (proto.hasPushCertificate()) {
-      patchSet.setPushCertificate(proto.getPushCertificate());
-    }
-    if (proto.hasDescription()) {
-      patchSet.setDescription(proto.getDescription());
-    }
-    return patchSet;
-  }
-
-  @Override
-  public Parser<Entities.PatchSet> getParser() {
-    return Entities.PatchSet.parser();
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
deleted file mode 100644
index b3c998b..0000000
--- a/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.converter;
-
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-
-public enum RevIdProtoConverter implements ProtoConverter<Entities.RevId, RevId> {
-  INSTANCE;
-
-  @Override
-  public Entities.RevId toProto(RevId revId) {
-    return Entities.RevId.newBuilder().setId(revId.get()).build();
-  }
-
-  @Override
-  public RevId fromProto(Entities.RevId proto) {
-    return new RevId(proto.getId());
-  }
-
-  @Override
-  public Parser<Entities.RevId> getParser() {
-    return Entities.RevId.parser();
-  }
-}
diff --git a/java/com/google/gerrit/server/ApprovalCopier.java b/java/com/google/gerrit/server/ApprovalCopier.java
deleted file mode 100644
index 09508bb..0000000
--- a/java/com/google/gerrit/server/ApprovalCopier.java
+++ /dev/null
@@ -1,336 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Table;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.LabelNormalizer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Copies approvals between patch sets.
- *
- * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
- */
-@Singleton
-public class ApprovalCopier {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ProjectCache projectCache;
-  private final ChangeKindCache changeKindCache;
-  private final LabelNormalizer labelNormalizer;
-  private final ChangeData.Factory changeDataFactory;
-  private final PatchSetUtil psUtil;
-
-  @Inject
-  ApprovalCopier(
-      ProjectCache projectCache,
-      ChangeKindCache changeKindCache,
-      LabelNormalizer labelNormalizer,
-      ChangeData.Factory changeDataFactory,
-      PatchSetUtil psUtil) {
-    this.projectCache = projectCache;
-    this.changeKindCache = changeKindCache;
-    this.labelNormalizer = labelNormalizer;
-    this.changeDataFactory = changeDataFactory;
-    this.psUtil = psUtil;
-  }
-
-  Iterable<PatchSetApproval> getForPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    return getForPatchSet(notes, psId, rw, repoConfig, Collections.emptyList());
-  }
-
-  Iterable<PatchSetApproval> getForPatchSet(
-      ChangeNotes notes,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy) {
-    PatchSet ps = psUtil.get(notes, psId);
-    if (ps == null) {
-      return Collections.emptyList();
-    }
-    return getForPatchSet(notes, ps, rw, repoConfig, dontCopy);
-  }
-
-  private Iterable<PatchSetApproval> getForPatchSet(
-      ChangeNotes notes,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy) {
-    requireNonNull(ps, "ps should not be null");
-    ChangeData cd = changeDataFactory.create(notes);
-    try {
-      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
-      ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
-      requireNonNull(all, "all should not be null");
-
-      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
-      for (PatchSetApproval psa : dontCopy) {
-        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-      }
-
-      Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
-      for (PatchSetApproval psa : all.get(ps.getId())) {
-        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
-        }
-      }
-
-      TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
-
-      // Walk patch sets strictly less than current in descending order.
-      Collection<PatchSet> allPrior =
-          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
-      for (PatchSet priorPs : allPrior) {
-        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
-        if (priorApprovals.isEmpty()) {
-          continue;
-        }
-
-        ChangeKind kind =
-            changeKindCache.getChangeKind(
-                project.getNameKey(),
-                rw,
-                repoConfig,
-                ObjectId.fromString(priorPs.getRevision().get()),
-                ObjectId.fromString(ps.getRevision().get()));
-
-        for (PatchSetApproval psa : priorApprovals) {
-          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-            continue;
-          }
-          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
-            continue;
-          }
-          if (!canCopy(project, psa, ps.getId(), kind)) {
-            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-            continue;
-          }
-          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
-        }
-      }
-      return labelNormalizer.normalize(notes, byUser.values()).getNormalized();
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) {
-    Collection<PatchSet> patchSets = cd.patchSets();
-    TreeMap<Integer, PatchSet> result = new TreeMap<>();
-    for (PatchSet ps : patchSets) {
-      result.put(ps.getId().get(), ps);
-    }
-    return result;
-  }
-
-  private static boolean canCopy(
-      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
-    int n = psa.getKey().getParentKey().get();
-    checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
-    if (type == null) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d cannot be copied"
-              + " to patch set %d because the label no longer exists on project %s",
-          psa.getValue(),
-          psa.getLabel(),
-          n,
-          psa.getKey().getParentKey().changeId.get(),
-          psId.get(),
-          project.getName());
-      return false;
-    } else if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
-      logger.atFine().log(
-          "veto approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMinScore = true on project %s",
-          psa.getValue(),
-          psa.getLabel(),
-          n,
-          psa.getKey().getParentKey().changeId.get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
-      logger.atFine().log(
-          "max approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMaxScore = true on project %s",
-          psa.getValue(),
-          psa.getLabel(),
-          n,
-          psa.getKey().getParentKey().changeId.get(),
-          psId.get(),
-          project.getName());
-      return true;
-    }
-    switch (kind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CODE_CHANGE:
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case TRIVIAL_REBASE:
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CHANGE:
-        if (type.isCopyAllScoresIfNoChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.getValue(),
-              psa.getLabel(),
-              n,
-              psa.getKey().getParentKey().changeId.get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case REWORK:
-      default:
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because change kind is %s",
-            psa.getValue(),
-            psa.getLabel(),
-            n,
-            psa.getKey().getParentKey().changeId.get(),
-            psId.get(),
-            kind);
-        return false;
-    }
-  }
-
-  private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
-    if (src.getKey().getParentKey().equals(psId)) {
-      return src;
-    }
-    return new PatchSetApproval(psId, src);
-  }
-}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
new file mode 100644
index 0000000..566a32b
--- /dev/null
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Computes approvals for a given patch set by looking at approvals applied to the given patch set
+ * and by additionally inferring approvals from the patch set's parents. The latter is done by
+ * asserting a change's kind and checking the project config for allowed forward-inference.
+ *
+ * <p>The result of a copy may either be stored, as when stamping approvals in the database at
+ * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
+ */
+@Singleton
+public class ApprovalInference {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ProjectCache projectCache;
+  private final ChangeKindCache changeKindCache;
+  private final LabelNormalizer labelNormalizer;
+
+  @Inject
+  ApprovalInference(
+      ProjectCache projectCache, ChangeKindCache changeKindCache, LabelNormalizer labelNormalizer) {
+    this.projectCache = projectCache;
+    this.changeKindCache = changeKindCache;
+    this.labelNormalizer = labelNormalizer;
+  }
+
+  /**
+   * Returns all approvals that apply to the given patch set. Honors direct and indirect (approval
+   * on parents) approvals.
+   */
+  Iterable<PatchSetApproval> forPatchSet(
+      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
+    ProjectState project;
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Computing labels for patch set",
+            Metadata.builder()
+                .changeId(notes.load().getChangeId().get())
+                .patchSetId(psId.get())
+                .build())) {
+      project = projectCache.checkedGet(notes.getProjectName());
+      Collection<PatchSetApproval> approvals =
+          getForPatchSetWithoutNormalization(notes, project, psId, rw, repoConfig);
+      return labelNormalizer.normalize(notes, approvals).getNormalized();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static boolean canCopy(
+      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
+    int n = psa.key().patchSetId().get();
+    checkArgument(n != psId.get());
+    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+    if (type == null) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d cannot be copied"
+              + " to patch set %d because the label no longer exists on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return false;
+    } else if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
+      logger.atFine().log(
+          "veto approval %s on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyMinScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
+    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
+      logger.atFine().log(
+          "max approval %s on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyMaxScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
+    } else if (type.isCopyAnyScore()) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyAnyScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
+    }
+    switch (kind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
+      case NO_CODE_CHANGE:
+        if (type.isCopyAllScoresIfNoCodeChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
+      case TRIVIAL_REBASE:
+        if (type.isCopyAllScoresOnTrivialRebase()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnTrivialRebase = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
+      case NO_CHANGE:
+        if (type.isCopyAllScoresIfNoChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresOnTrivialRebase()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnTrivialRebase = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresIfNoCodeChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
+      case REWORK:
+      default:
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because change kind is %s",
+            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
+        return false;
+    }
+  }
+
+  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
+      ChangeNotes notes,
+      ProjectState project,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig) {
+    checkState(
+        project.getNameKey().equals(notes.getProjectName()),
+        "project must match %s, %s",
+        project.getNameKey(),
+        notes.getProjectName());
+
+    PatchSet ps = notes.load().getPatchSets().get(psId);
+    if (ps == null) {
+      return Collections.emptyList();
+    }
+
+    // Add approvals on the given patch set to the result
+    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> approvalsForGivenPatchSet =
+        notes.load().getApprovals().get(ps.id());
+    approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
+
+    // Bail out immediately if this is the first patch set. Return only approvals granted on the
+    // given patch set.
+    if (psId.get() == 1) {
+      return resultByUser.values();
+    }
+
+    // Call this algorithm recursively to check if the prior patch set had approvals. This has the
+    // advantage that all caches - most importantly ChangeKindCache - have values cached for what we
+    // need for this computation.
+    // The way this algorithm is written is that any approval will be copied forward by one patch
+    // set at a time if configs and change kind allow so. Once an approval is held back - for
+    // example because the patch set is a REWORK - it will not be picked up again in a future
+    // patch set.
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
+    if (priorPatchSet == null) {
+      return resultByUser.values();
+    }
+
+    Iterable<PatchSetApproval> priorApprovals =
+        getForPatchSetWithoutNormalization(
+            notes, project, priorPatchSet.getValue().id(), rw, repoConfig);
+    if (!priorApprovals.iterator().hasNext()) {
+      return resultByUser.values();
+    }
+
+    // Add labels from the previous patch set to the result in case the label isn't already there
+    // and settings as well as change kind allow copying.
+    ChangeKind kind =
+        changeKindCache.getChangeKind(
+            project.getNameKey(),
+            rw,
+            repoConfig,
+            priorPatchSet.getValue().commitId(),
+            ps.commitId());
+    logger.atFine().log(
+        "change kind for patch set %d of change %d against prior patch set %s is %s",
+        ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
+    for (PatchSetApproval psa : priorApprovals) {
+      if (resultByUser.contains(psa.label(), psa.accountId())) {
+        continue;
+      }
+      if (!canCopy(project, psa, ps.id(), kind)) {
+        continue;
+      }
+      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
+    }
+    return resultByUser.values();
+  }
+}
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 135276e..58b601f 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -25,20 +25,19 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -71,38 +70,38 @@
  * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
  * "no score" case, a dummy approval, which may live in any of the available categories, with a
  * score of 0 is used.
- *
- * <p>The methods in this class only modify the gwtorm database.
  */
 @Singleton
 public class ApprovalsUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static PatchSetApproval newApproval(
+  public static PatchSetApproval.Builder newApproval(
       PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, user.getAccountId(), labelId),
-            Shorts.checkedCast(value),
-            when);
-    user.updateRealAccountId(psa::setRealAccountId);
-    return psa;
+    PatchSetApproval.Builder b =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
+            .value(value)
+            .granted(when);
+    user.updateRealAccountId(b::realAccountId);
+    return b;
   }
 
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, Account.Id accountId) {
-    return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
+    return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalCopier copier;
+  private final ApprovalInference approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalCopier copier, PermissionBackend permissionBackend, ProjectCache projectCache) {
-    this.copier = copier;
+      ApprovalInference approvalInference,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache) {
+    this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
   }
@@ -149,7 +148,7 @@
         update,
         labelTypes,
         change,
-        ps.getId(),
+        ps.id(),
         info.getAuthor().getAccount(),
         info.getCommitter().getAccount(),
         wantReviewers,
@@ -209,8 +208,11 @@
     LabelId labelId = Iterables.getLast(allTypes).getLabelId();
     for (Account.Id account : need) {
       cells.add(
-          new PatchSetApproval(
-              new PatchSetApproval.Key(psId, account, labelId), (short) 0, update.getWhen()));
+          PatchSetApproval.builder()
+              .key(PatchSetApproval.key(psId, account, labelId))
+              .value(0)
+              .granted(update.getWhen())
+              .build());
       update.putReviewer(account, REVIEWER);
     }
     return Collections.unmodifiableList(cells);
@@ -239,17 +241,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);
@@ -276,10 +289,10 @@
       throws RestApiException, PermissionBackendException {
     Account.Id accountId = user.getAccountId();
     checkArgument(
-        accountId.equals(ps.getUploader()),
+        accountId.equals(ps.uploader()),
         "expected user %s to match patch set uploader %s",
         accountId,
-        ps.getUploader());
+        ps.uploader());
     if (approvals.isEmpty()) {
       return ImmutableList.of();
     }
@@ -288,10 +301,10 @@
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
+      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
-      update.putApproval(psa.getLabel(), psa.getValue());
+      update.putApproval(psa.label(), psa.value());
     }
     return cells;
   }
@@ -329,7 +342,7 @@
 
   public Iterable<PatchSetApproval> byPatchSet(
       ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    return copier.getForPatchSet(notes, psId, rw, repoConfig);
+    return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
@@ -359,8 +372,8 @@
     }
     PatchSetApproval submitter = null;
     for (PatchSetApproval a : approvals) {
-      if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isLegacySubmit()) {
-        if (submitter == null || a.getGranted().compareTo(submitter.getGranted()) > 0) {
+      if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) {
+        if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) {
           submitter = a;
         }
       }
@@ -374,7 +387,7 @@
     if (!n.isEmpty()) {
       boolean first = true;
       for (Map.Entry<String, Short> e : n.entrySet()) {
-        if (c.containsKey(e.getKey()) && c.get(e.getKey()).getValue() == e.getValue()) {
+        if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) {
           continue;
         }
         if (first) {
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
new file mode 100644
index 0000000..3d6242b
--- /dev/null
+++ b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/** Change to an assignee's status. */
+@AutoValue
+public abstract class AssigneeStatusUpdate {
+  public static AssigneeStatusUpdate create(
+      Timestamp ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
+    return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
+  }
+
+  public abstract Timestamp date();
+
+  public abstract Account.Id updatedBy();
+
+  public abstract Optional<Account.Id> currentAssignee();
+}
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index d2b7584..6675595 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -9,6 +9,11 @@
     "config/GerritGlobalModule.java",
 ]
 
+TESTING_SRC = [
+    "account/externalids/testing/ExternalIdInserter.java",
+    "account/externalids/testing/ExternalIdTestUtil.java",
+]
+
 java_library(
     name = "constants",
     srcs = CONSTANTS_SRC,
@@ -25,7 +30,7 @@
     name = "server",
     srcs = glob(
         ["**/*.java"],
-        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC,
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
@@ -34,6 +39,7 @@
         ":constants",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
@@ -47,7 +53,6 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
@@ -55,7 +60,6 @@
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
-        "//java/com/google/gwtorm",
         "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
@@ -89,11 +93,13 @@
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-archive",
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
         "//lib/auto:auto-value",
@@ -110,8 +116,6 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jsoup",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
@@ -138,12 +142,13 @@
         ":server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//lib:blame-cache",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:soy",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
 
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 97ba8f0..5f00b69 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -18,10 +18,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -72,10 +72,7 @@
     Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
     ChangeMessage m =
         new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUuid()),
-            accountId,
-            when,
-            psId);
+            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
     m.setMessage(body);
     m.setTag(tag);
     user.updateRealAccountId(m::setRealAuthor);
@@ -127,7 +124,7 @@
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
     ChangeMessageInfo cmi = new ChangeMessageInfo();
-    cmi.id = message.getKey().get();
+    cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
     cmi.date = message.getWrittenOn();
     cmi.message = message.getMessage();
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index b8a00f4..ee82a26 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -19,8 +19,8 @@
 
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
@@ -40,7 +40,7 @@
   private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
   public static final Ordering<PatchSet> PS_ID_ORDER =
-      Ordering.from(comparingInt(PatchSet::getPatchSetId));
+      Ordering.from(comparingInt(PatchSet::number));
 
   /** @return a new unique identifier for change message entities. */
   public static String messageUuid() {
@@ -83,7 +83,7 @@
     Set<PatchSet.Id> existing =
         changeRefNames
             .map(PatchSet.Id::fromRef)
-            .filter(psId -> psId != null && psId.getParentKey().equals(id.getParentKey()))
+            .filter(psId -> psId != null && psId.changeId().equals(id.changeId()))
             .collect(toSet());
     PatchSet.Id next = nextPatchSetId(id);
     while (existing.contains(next)) {
@@ -103,7 +103,7 @@
    * @return next patch set ID for the same change, incrementing by 1.
    */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
-    return new PatchSet.Id(id.getParentKey(), id.get() + 1);
+    return PatchSet.id(id.changeId(), id.get() + 1);
   }
 
   /**
@@ -116,7 +116,7 @@
    */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
     return nextPatchSetIdFromChangeRefs(
-        git.getRefDatabase().getRefsByPrefix(id.getParentKey().toRefPrefix()).stream()
+        git.getRefDatabase().getRefsByPrefix(id.changeId().toRefPrefix()).stream()
             .map(Ref::getName),
         id);
   }
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index d7f6e30..d943889 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.args4j.AccountGroupIdHandler;
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index a5332eb..ae4ba4b 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -19,22 +19,20 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -50,7 +48,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -92,7 +89,7 @@
       };
 
   public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
-    return new PatchSet.Id(changeId, comment.key.patchSetId);
+    return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
   public static String extractMessageId(@Nullable String tag) {
@@ -131,7 +128,7 @@
         unresolved = false;
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
-        Comment.Key key = new Comment.Key(parentUuid, path, psId.patchSetId);
+        Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
         Optional<Comment> parent = getPublished(ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
@@ -260,8 +257,7 @@
     return sort(comments);
   }
 
-  public void putComments(
-      ChangeUpdate update, PatchLineComment.Status status, Iterable<Comment> comments) {
+  public void putComments(ChangeUpdate update, Comment.Status status, Iterable<Comment> comments) {
     for (Comment c : comments) {
       update.putComment(status, c);
     }
@@ -306,22 +302,22 @@
     return sort(result);
   }
 
-  public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
+  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
       throws PatchListNotAvailableException {
     checkArgument(
-        c.key.patchSetId == ps.getId().get(),
-        "cannot set RevId for patch set %s on comment %s",
-        ps.getId(),
+        c.key.patchSetId == ps.id().get(),
+        "cannot set commit ID for patch set %s on comment %s",
+        ps.id(),
         c);
-    if (c.revId == null) {
+    if (c.getCommitId() == null) {
       if (Side.fromShort(c.side) == Side.PARENT) {
         if (c.side < 0) {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
+          c.setCommitId(cache.getOldId(change, ps, -c.side));
         } else {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
+          c.setCommitId(cache.getOldId(change, ps, null));
         }
       } else {
-        c.revId = ps.getRevision().get();
+        c.setCommitId(ps.commitId());
       }
     }
   }
@@ -354,15 +350,4 @@
     comments.sort(COMMENT_ORDER);
     return comments;
   }
-
-  public static Iterable<PatchLineComment> toPatchLineComments(
-      Change.Id changeId, PatchLineComment.Status status, Iterable<Comment> comments) {
-    return FluentIterable.from(comments).transform(c -> PatchLineComment.from(changeId, status, c));
-  }
-
-  public static List<Comment> toComments(
-      final String serverId, Iterable<PatchLineComment> comments) {
-    return COMMENT_ORDER.sortedCopy(
-        FluentIterable.from(comments).transform(plc -> plc.asComment(serverId)));
-  }
 }
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 27fb9d7..996257c 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 03b9f54..75afc04 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.inject.servlet.RequestScoped;
diff --git a/java/com/google/gerrit/server/ExceptionHook.java b/java/com/google/gerrit/server/ExceptionHook.java
new file mode 100644
index 0000000..a0d98d2
--- /dev/null
+++ b/java/com/google/gerrit/server/ExceptionHook.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Allows implementors to control how certain exceptions should be handled.
+ *
+ * <p>This interface is intended to be implemented for cluster setups with multiple primary nodes to
+ * control the behavior for handling exceptions that are thrown by a lower layer that handles the
+ * consensus and synchronization between different server nodes. E.g. if an operation fails because
+ * consensus for a Git update could not be achieved (e.g. due to slow responding server nodes) this
+ * interface can be used to retry the request instead of failing it immediately.
+ */
+@ExtensionPoint
+public interface ExceptionHook {
+  /**
+   * Whether an operation should be retried if it failed with the given throwable.
+   *
+   * <p>Only affects operations that are executed with {@link
+   * com.google.gerrit.server.update.RetryHelper}.
+   *
+   * @param throwable throwable that was thrown while executing the operation
+   * @return whether the operation should be retried
+   */
+  default boolean shouldRetry(Throwable throwable) {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index e65f562..7cafdc0 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -22,7 +22,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
@@ -234,7 +234,7 @@
         groupBackend,
         enableReverseDnsLookup,
         remotePeerProvider,
-        state.getAccount().getId(),
+        state.account().id(),
         realUser);
     this.state = state;
   }
@@ -323,15 +323,14 @@
    */
   @Override
   public Optional<String> getUserName() {
-    return state().getUserName();
+    return state().userName();
   }
 
   /** @return unique name of the user for logging, never {@code null} */
   @Override
   public String getLoggableName() {
     return getUserName()
-        .orElseGet(
-            () -> firstNonNull(getAccount().getPreferredEmail(), "a/" + getAccountId().get()));
+        .orElseGet(() -> firstNonNull(getAccount().preferredEmail(), "a/" + getAccountId().get()));
   }
 
   /**
@@ -340,7 +339,7 @@
    * @return the account of the identified user, an empty account if the account is missing
    */
   public Account getAccount() {
-    return state().getAccount();
+    return state().account();
   }
 
   public boolean hasEmailAddress(String email) {
@@ -377,7 +376,7 @@
   @Override
   public GroupMembership getEffectiveGroups() {
     if (effectiveGroups == null) {
-      if (authConfig.isIdentityTrustable(state().getExternalIds())) {
+      if (authConfig.isIdentityTrustable(state().externalIds())) {
         effectiveGroups = groupBackend.membershipsOf(this);
         logger.atFinest().log(
             "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
@@ -403,29 +402,29 @@
   public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
 
-    String name = ua.getFullName();
+    String name = ua.fullName();
     if (name == null || name.isEmpty()) {
-      name = ua.getPreferredEmail();
+      name = ua.preferredEmail();
     }
     if (name == null || name.isEmpty()) {
       name = anonymousCowardName;
     }
 
-    String user = getUserName().orElse("") + "|account-" + ua.getId().toString();
+    String user = getUserName().orElse("") + "|account-" + ua.id().toString();
     return new PersonIdent(name, user + "@" + guessHost(), when, tz);
   }
 
   public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
-    String name = ua.getFullName();
-    String email = ua.getPreferredEmail();
+    String name = ua.fullName();
+    String email = ua.preferredEmail();
 
     if (email == null || email.isEmpty()) {
       // No preferred email is configured. Use a generic identity so we
       // don't leak an address the user may have given us, but doesn't
       // necessarily want to publish through Git records.
       //
-      String user = getUserName().orElseGet(() -> "account-" + ua.getId().toString());
+      String user = getUserName().orElseGet(() -> "account-" + ua.id().toString());
 
       String host;
       if (canonicalUrl.get() != null) {
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 2a78eb6..b53e666 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -20,14 +20,14 @@
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -39,6 +39,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -89,8 +90,8 @@
       PatchSet.Id psId,
       ObjectId commit,
       List<String> groups,
-      String pushCertificate,
-      String description)
+      @Nullable String pushCertificate,
+      @Nullable String description)
       throws IOException {
     requireNonNull(groups, "groups may not be null");
     ensurePatchSetMatches(psId, update);
@@ -99,20 +100,21 @@
     update.setPsDescription(description);
     update.setGroups(groups);
 
-    PatchSet ps = new PatchSet(psId);
-    ps.setRevision(new RevId(commit.name()));
-    ps.setUploader(update.getAccountId());
-    ps.setCreatedOn(new Timestamp(update.getWhen().getTime()));
-    ps.setGroups(groups);
-    ps.setPushCertificate(pushCertificate);
-    ps.setDescription(description);
-    return ps;
+    return PatchSet.builder()
+        .id(psId)
+        .commitId(commit)
+        .uploader(update.getAccountId())
+        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .groups(groups)
+        .pushCertificate(Optional.ofNullable(pushCertificate))
+        .description(Optional.ofNullable(description))
+        .build();
   }
 
   private static void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
-    Change.Id changeId = update.getChange().getId();
+    Change.Id changeId = update.getId();
     checkArgument(
-        psId.getParentKey().equals(changeId),
+        psId.changeId().equals(changeId),
         "cannot modify patch set %s on update for change %s",
         psId,
         changeId);
@@ -127,11 +129,6 @@
     }
   }
 
-  public void setGroups(ChangeUpdate update, PatchSet ps, List<String> groups) {
-    ps.setGroups(groups);
-    update.setGroups(groups);
-  }
-
   /** Check if the current patch set of the change is locked. */
   public void checkPatchSetNotLocked(ChangeNotes notes)
       throws IOException, ResourceConflictException {
@@ -155,10 +152,8 @@
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap :
         approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
-      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.getLabel());
-      if (type != null
-          && ap.getValue() == 1
-          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
@@ -169,7 +164,7 @@
   public RevCommit getRevCommit(Project.NameKey project, PatchSet patchSet) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      RevCommit src = rw.parseCommit(patchSet.commitId());
       rw.parseBody(src);
       return src;
     }
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/ProjectUtil.java
index 1db4aa3..fa056b3 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/server/ProjectUtil.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -33,13 +33,14 @@
    * @throws RepositoryNotFoundException the repository of the branch's project does not exist.
    * @throws IOException error while retrieving the branch from the repository.
    */
-  public static boolean branchExists(final GitRepositoryManager repoManager, Branch.NameKey branch)
+  public static boolean branchExists(final GitRepositoryManager repoManager, BranchNameKey branch)
       throws RepositoryNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(branch.getParentKey())) {
-      boolean exists = repo.getRefDatabase().exactRef(branch.get()) != null;
+    try (Repository repo = repoManager.openRepository(branch.project())) {
+      boolean exists = repo.getRefDatabase().exactRef(branch.branch()) != null;
       if (!exists) {
         exists =
-            repo.getFullBranch().equals(branch.get()) || RefNames.REFS_CONFIG.equals(branch.get());
+            repo.getFullBranch().equals(branch.branch())
+                || RefNames.REFS_CONFIG.equals(branch.branch());
       }
       return exists;
     }
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index ad93ef0..26539c5 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -15,16 +15,21 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
+import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -46,35 +51,55 @@
   }
 
   public void publish(
-      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag) {
+      ChangeContext ctx,
+      PatchSet.Id psId,
+      Collection<Comment> draftComments,
+      @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
     checkArgument(notes != null);
-    if (drafts.isEmpty()) {
+    if (draftComments.isEmpty()) {
       return;
     }
 
     Map<PatchSet.Id, PatchSet> patchSets =
-        psUtil.getAsMap(notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
-    for (Comment d : drafts) {
-      PatchSet ps = patchSets.get(psId(notes, d));
+        psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
+    for (Comment draftComment : draftComments) {
+      PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
+      PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
-        throw new StorageException("patch set " + ps + " not found");
+        throw new StorageException("patch set " + psIdOfDraftComment + " not found");
       }
-      d.writtenOn = ctx.getWhen();
-      d.tag = tag;
+      draftComment.writtenOn = ctx.getWhen();
+      draftComment.tag = tag;
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
-      ctx.getUser().updateRealAccountId(d::setRealAuthor);
+      ctx.getUser().updateRealAccountId(draftComment::setRealAuthor);
       try {
-        CommentsUtil.setCommentRevId(d, patchListCache, notes.getChange(), ps);
+        CommentsUtil.setCommentCommitId(draftComment, patchListCache, notes.getChange(), ps);
       } catch (PatchListNotAvailableException e) {
         throw new StorageException(e);
       }
     }
-    commentsUtil.putComments(ctx.getUpdate(psId), PUBLISHED, drafts);
+    commentsUtil.putComments(ctx.getUpdate(psId), Status.PUBLISHED, draftComments);
   }
 
   private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
-    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+    return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+  }
+
+  /**
+   * Helper to run the specified set of {@link CommentValidator}-s on the specified comments.
+   *
+   * @return See {@link CommentValidator#validateComments(ImmutableList)}.
+   */
+  public static ImmutableList<CommentValidationFailure> findInvalidComments(
+      PluginSetContext<CommentValidator> commentValidators,
+      ImmutableList<CommentForValidation> commentsForValidation) {
+    ImmutableList.Builder<CommentValidationFailure> commentValidationFailures =
+        new ImmutableList.Builder<>();
+    commentValidators.runEach(
+        listener ->
+            commentValidationFailures.addAll(listener.validateComments(commentsForValidation)));
+    return commentValidationFailures.build();
   }
 }
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
new file mode 100644
index 0000000..f369239
--- /dev/null
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.logging.TraceContext;
+import java.util.Optional;
+
+/** Information about a request that was received from a user. */
+@AutoValue
+public abstract class RequestInfo {
+  /** Channel through which a user request was received. */
+  public enum RequestType {
+    /** request type for git push */
+    GIT_RECEIVE,
+
+    /** request type for git fetch */
+    GIT_UPLOAD,
+
+    /** request type for call to REST API */
+    REST,
+
+    /** request type for call to SSH API */
+    SSH
+  }
+
+  /**
+   * Type of the request, telling through which channel the request was coming in.
+   *
+   * <p>See {@link RequestType} for the types that are used by Gerrit core. Other request types are
+   * possible, e.g. if a plugin supports receiving requests through another channel.
+   */
+  public abstract String requestType();
+
+  /**
+   * Request URI.
+   *
+   * <p>Only set if request type is {@link RequestType#REST}.
+   *
+   * <p>Never includes the "/a" prefix.
+   */
+  public abstract Optional<String> requestUri();
+
+  /** The user that has sent the request. */
+  public abstract CurrentUser callingUser();
+
+  /** The trace context of the request. */
+  public abstract TraceContext traceContext();
+
+  /**
+   * The name of the project for which the request is being done. Only available if the request is
+   * tied to a project or change. If a project is available it's not guaranteed that it actually
+   * exists (e.g. if a user made a request for a project that doesn't exist).
+   */
+  public abstract Optional<Project.NameKey> project();
+
+  public static RequestInfo.Builder builder(
+      RequestType requestType, CurrentUser callingUser, TraceContext traceContext) {
+    return new AutoValue_RequestInfo.Builder()
+        .requestType(requestType)
+        .callingUser(callingUser)
+        .traceContext(traceContext);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder requestType(String requestType);
+
+    public Builder requestType(RequestType requestType) {
+      return requestType(requestType.name());
+    }
+
+    public abstract Builder requestUri(String requestUri);
+
+    public abstract Builder callingUser(CurrentUser callingUser);
+
+    public abstract Builder traceContext(TraceContext traceContext);
+
+    public abstract Builder project(Project.NameKey projectName);
+
+    public abstract RequestInfo build();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/CodedEnum.java b/java/com/google/gerrit/server/RequestListener.java
similarity index 68%
copy from java/com/google/gerrit/reviewdb/client/CodedEnum.java
copy to java/com/google/gerrit/server/RequestListener.java
index 11e7efa..461b91a 100644
--- a/java/com/google/gerrit/reviewdb/client/CodedEnum.java
+++ b/java/com/google/gerrit/server/RequestListener.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// 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.
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.server;
 
-/** Extension of Enum which provides distinct character code values. */
-public interface CodedEnum {
-  char getCode();
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+@ExtensionPoint
+public interface RequestListener {
+  void onRequest(RequestInfo requestInfo);
 }
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index f36e3ab..0f6bf29 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -22,8 +22,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
@@ -44,18 +44,14 @@
         first = psa;
       } else {
         checkArgument(
-            first
-                .getKey()
-                .getParentKey()
-                .getParentKey()
-                .equals(psa.getKey().getParentKey().getParentKey()),
+            first.key().patchSetId().changeId().equals(psa.key().patchSetId().changeId()),
             "multiple change IDs: %s, %s",
-            first.getKey(),
-            psa.getKey());
+            first.key(),
+            psa.key());
       }
-      Account.Id id = psa.getAccountId();
-      reviewers.put(REVIEWER, id, psa.getGranted());
-      if (psa.getValue() != 0) {
+      Account.Id id = psa.accountId();
+      reviewers.put(REVIEWER, id, psa.granted());
+      if (psa.value() != 0) {
         reviewers.remove(CC, id);
       }
     }
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index c4f3e2a..938d985 100644
--- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index deca550..5824240 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -31,18 +31,20 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -89,7 +91,7 @@
         if (id == null) {
           return null;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         String label = s.substring(p + 1);
         return create(accountId, label);
       }
@@ -234,7 +236,16 @@
     }
   }
 
-  public void unstarAll(Project.NameKey project, Change.Id changeId) {
+  /**
+   * Unstar the given change for all users.
+   *
+   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
+   * not reindexed.
+   *
+   * @param changeId change ID.
+   * @throws IOException if an error occurred.
+   */
+  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
@@ -244,7 +255,9 @@
       for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
-        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
+        if (ref != null) {
+          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
+        }
       }
       batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
       for (ReceiveCommand command : batchUpdate.getCommands()) {
@@ -256,12 +269,9 @@
           if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
             throw new LockFailureException(message, batchUpdate);
           }
-          throw new IOException(message);
+          throw new GitUpdateFailureException(message, batchUpdate);
         }
       }
-      indexer.index(project, changeId);
-    } catch (IOException e) {
-      throw new StorageException(String.format("Unstar change %d failed", changeId.get()), e);
     }
   }
 
@@ -273,7 +283,7 @@
         if (id == null) {
           continue;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -375,7 +385,9 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    try (TraceTimer traceTimer = TraceContext.newTimer("Read star labels from %s", refName)) {
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
       Ref ref = repo.exactRef(refName);
       if (ref == null) {
         return StarRef.MISSING;
@@ -449,7 +461,9 @@
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
       throws IOException, InvalidLabelsException {
     try (TraceTimer traceTimer =
-            TraceContext.newTimer("Update star labels in %s (labels=%s)", refName, labels);
+            TraceContext.newTimer(
+                "Update star labels",
+                Metadata.builder().noteDbRefName(refName).resourceCount(labels.size()).build());
         RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -486,7 +500,9 @@
       return;
     }
 
-    try (TraceTimer traceTimer = TraceContext.newTimer("Delete star labels in %s", refName)) {
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Delete star labels", Metadata.builder().noteDbRefName(refName).build())) {
       RefUpdate u = repo.updateRef(refName);
       u.setForceUpdate(true);
       u.setExpectedOldObjectId(oldObjectId);
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
new file mode 100644
index 0000000..20c9f57
--- /dev/null
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -0,0 +1,228 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Request listener that sets additional logging tags and enables tracing automatically if the
+ * request matches any tracing configuration in gerrit.config (see description of
+ * 'tracing.<trace-id>' subsection in config-gerrit.txt).
+ */
+@Singleton
+public class TraceRequestListener implements RequestListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Config cfg;
+  private final ImmutableList<TraceConfig> traceConfigs;
+
+  @Inject
+  TraceRequestListener(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    this.traceConfigs = parseTraceConfigs();
+  }
+
+  @Override
+  public void onRequest(RequestInfo requestInfo) {
+    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag("project", p));
+    traceConfigs.stream()
+        .filter(traceConfig -> traceConfig.matches(requestInfo))
+        .forEach(
+            traceConfig ->
+                requestInfo
+                    .traceContext()
+                    .forceLogging()
+                    .addTag(RequestId.Type.TRACE_ID, traceConfig.traceId()));
+  }
+
+  private ImmutableList<TraceConfig> parseTraceConfigs() {
+    ImmutableList.Builder<TraceConfig> traceConfigs = ImmutableList.builder();
+
+    for (String traceId : cfg.getSubsections("tracing")) {
+      try {
+        TraceConfig.Builder traceConfig = TraceConfig.builder();
+        traceConfig.traceId(traceId);
+        traceConfig.requestTypes(parseRequestTypes(traceId));
+        traceConfig.requestUriPatterns(parseRequestUriPatterns(traceId));
+        traceConfig.accountIds(parseAccounts(traceId));
+        traceConfig.projectPatterns(parseProjectPatterns(traceId));
+        traceConfigs.add(traceConfig.build());
+      } catch (ConfigInvalidException e) {
+        logger.atWarning().log("Ignoring invalid tracing configuration:\n %s", e.getMessage());
+      }
+    }
+
+    return traceConfigs.build();
+  }
+
+  private ImmutableSet<String> parseRequestTypes(String traceId) {
+    return ImmutableSet.copyOf(cfg.getStringList("tracing", traceId, "requestType"));
+  }
+
+  private ImmutableSet<Pattern> parseRequestUriPatterns(String traceId)
+      throws ConfigInvalidException {
+    return parsePatterns(traceId, "requestUriPattern");
+  }
+
+  private ImmutableSet<Account.Id> parseAccounts(String traceId) throws ConfigInvalidException {
+    ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
+    String[] accounts = cfg.getStringList("tracing", traceId, "account");
+    for (String account : accounts) {
+      Optional<Account.Id> accountId = Account.Id.tryParse(account);
+      if (!accountId.isPresent()) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.account = %s'): invalid account ID",
+                traceId, account));
+      }
+      accountIds.add(accountId.get());
+    }
+    return accountIds.build();
+  }
+
+  private ImmutableSet<Pattern> parseProjectPatterns(String traceId) throws ConfigInvalidException {
+    return parsePatterns(traceId, "projectPattern");
+  }
+
+  private ImmutableSet<Pattern> parsePatterns(String traceId, String name)
+      throws ConfigInvalidException {
+    ImmutableSet.Builder<Pattern> patterns = ImmutableSet.builder();
+    String[] patternRegExs = cfg.getStringList("tracing", traceId, name);
+    for (String patternRegEx : patternRegExs) {
+      try {
+        patterns.add(Pattern.compile(patternRegEx));
+      } catch (PatternSyntaxException e) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.%s = %s'): %s",
+                traceId, name, patternRegEx, e.getMessage()));
+      }
+    }
+    return patterns.build();
+  }
+
+  @AutoValue
+  abstract static class TraceConfig {
+    /** ID for the trace */
+    abstract String traceId();
+
+    /** request types that should be traced */
+    abstract ImmutableSet<String> requestTypes();
+
+    /** pattern matching request URIs */
+    abstract ImmutableSet<Pattern> requestUriPatterns();
+
+    /** accounts IDs matching calling user */
+    abstract ImmutableSet<Account.Id> accountIds();
+
+    /** pattern matching projects names */
+    abstract ImmutableSet<Pattern> projectPatterns();
+
+    static Builder builder() {
+      return new AutoValue_TraceRequestListener_TraceConfig.Builder();
+    }
+
+    /**
+     * Whether this trace config matches a given request.
+     *
+     * @param requestInfo request info
+     * @return whether this trace config matches
+     */
+    boolean matches(RequestInfo requestInfo) {
+      // If in the trace config request types are set and none of them matches, then the request is
+      // not matched.
+      if (!requestTypes().isEmpty()
+          && requestTypes().stream()
+              .noneMatch(type -> type.equalsIgnoreCase(requestInfo.requestType()))) {
+        return false;
+      }
+
+      // If in the trace config request URI patterns are set and none of them matches, then the
+      // request is not matched.
+      if (!requestUriPatterns().isEmpty()) {
+        if (!requestInfo.requestUri().isPresent()) {
+          // The request has no request URI, hence it cannot match a request URI pattern.
+          return false;
+        }
+
+        if (requestUriPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
+          return false;
+        }
+      }
+
+      // If in the trace config accounts are set and none of them matches, then the request is not
+      // matched.
+      if (!accountIds().isEmpty()) {
+        try {
+          if (accountIds().stream()
+              .noneMatch(id -> id.equals(requestInfo.callingUser().getAccountId()))) {
+            return false;
+          }
+        } catch (UnsupportedOperationException e) {
+          // The calling user is not logged in, hence it cannot match an account.
+          return false;
+        }
+      }
+
+      // If in the trace config project patterns are set and none of them matches, then the request
+      // is not matched.
+      if (!projectPatterns().isEmpty()) {
+        if (!requestInfo.project().isPresent()) {
+          // The request is not for a project, hence it cannot match a project pattern.
+          return false;
+        }
+
+        if (projectPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.project().get().get()).matches())) {
+          return false;
+        }
+      }
+
+      // For any match criteria (request type, request URI pattern, account, project pattern) that
+      // was specified in the trace config, at least one of the configured value matched the
+      // request.
+      return true;
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder traceId(String traceId);
+
+      abstract Builder requestTypes(ImmutableSet<String> requestTypes);
+
+      abstract Builder requestUriPatterns(ImmutableSet<Pattern> requestUriPatterns);
+
+      abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
+
+      abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
+
+      abstract TraceConfig build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 589344c..88b0b21 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -31,12 +34,10 @@
 import com.google.gerrit.extensions.webui.ProjectWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
@@ -87,7 +88,7 @@
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
     return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
   }
 
@@ -96,7 +97,7 @@
    * @param revision SHA1 of the parent revision.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
+  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
     return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
   }
 
@@ -106,9 +107,9 @@
    * @param file File name.
    * @return Links for files.
    */
-  public List<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
     return Patch.isMagic(file)
-        ? Collections.emptyList()
+        ? ImmutableList.of()
         : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
   }
 
@@ -118,14 +119,15 @@
    * @param file File name.
    * @return Links for file history
    */
-  public List<WebLinkInfo> getFileHistoryLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileHistoryLinks(
+      String project, String revision, String file) {
     if (Patch.isMagic(file)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(fileHistoryLinks)
-        .transform(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
+    return Streams.stream(fileHistoryLinks)
+        .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
         .filter(INVALID_WEBLINK)
-        .toList();
+        .collect(toImmutableList());
   }
 
   /**
@@ -138,20 +140,20 @@
    * @param fileB File name of side B.
    * @return Links for file diffs.
    */
-  public List<DiffWebLinkInfo> getDiffLinks(
-      final String project,
-      final int changeId,
-      final Integer patchSetIdA,
-      final String revisionA,
-      final String fileA,
-      final int patchSetIdB,
-      final String revisionB,
-      final String fileB) {
+  public ImmutableList<DiffWebLinkInfo> getDiffLinks(
+      String project,
+      int changeId,
+      Integer patchSetIdA,
+      String revisionA,
+      String fileA,
+      int patchSetIdB,
+      String revisionB,
+      String fileB) {
     if (Patch.isMagic(fileA) || Patch.isMagic(fileB)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(diffLinks)
-        .transform(
+    return Streams.stream(diffLinks)
+        .map(
             webLink ->
                 webLink.getDiffLink(
                     project,
@@ -163,14 +165,14 @@
                     revisionB,
                     fileB))
         .filter(INVALID_WEBLINK)
-        .toList();
+        .collect(toImmutableList());
   }
 
   /**
    * @param project Project name.
    * @return Links for projects.
    */
-  public List<WebLinkInfo> getProjectLinks(String project) {
+  public ImmutableList<WebLinkInfo> getProjectLinks(String project) {
     return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
   }
 
@@ -179,7 +181,7 @@
    * @param branch Branch name
    * @return Links for branches.
    */
-  public List<WebLinkInfo> getBranchLinks(String project, String branch) {
+  public ImmutableList<WebLinkInfo> getBranchLinks(String project, String branch) {
     return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
@@ -188,12 +190,15 @@
    * @param tag Tag name
    * @return Links for tags.
    */
-  public List<WebLinkInfo> getTagLinks(String project, String tag) {
+  public ImmutableList<WebLinkInfo> getTagLinks(String project, String tag) {
     return filterLinks(tagLinks, webLink -> webLink.getTagWebLink(project, tag));
   }
 
-  private <T extends WebLink> List<WebLinkInfo> filterLinks(
+  private <T extends WebLink> ImmutableList<WebLinkInfo> filterLinks(
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
-    return FluentIterable.from(links).transform(transformer).filter(INVALID_WEBLINK).toList();
+    return Streams.stream(links)
+        .map(transformer)
+        .filter(INVALID_WEBLINK)
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/account/AbstractGroupBackend.java b/java/com/google/gerrit/server/account/AbstractGroupBackend.java
index b50b003..93241d6 100644
--- a/java/com/google/gerrit/server/account/AbstractGroupBackend.java
+++ b/java/com/google/gerrit/server/account/AbstractGroupBackend.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 public abstract class AbstractGroupBackend implements GroupBackend {
   @Override
diff --git a/java/com/google/gerrit/server/account/AbstractRealm.java b/java/com/google/gerrit/server/account/AbstractRealm.java
index e61736d..380001d 100644
--- a/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -53,7 +53,7 @@
 
   @Override
   public boolean hasEmailAddress(IdentifiedUser user, String email) {
-    for (ExternalId ext : user.state().getExternalIds()) {
+    for (ExternalId ext : user.state().externalIds()) {
       if (email != null && email.equalsIgnoreCase(ext.email())) {
         return true;
       }
@@ -63,7 +63,7 @@
 
   @Override
   public Set<String> getEmailAddresses(IdentifiedUser user) {
-    Collection<ExternalId> ids = user.state().getExternalIds();
+    Collection<ExternalId> ids = user.state().externalIds();
     Set<String> emails = Sets.newHashSetWithExpectedSize(ids.size());
     for (ExternalId ext : ids) {
       if (!Strings.isNullOrEmpty(ext.email())) {
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 17493bf..47cf25b 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index acd7bd3..ef4e1c0 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -21,12 +21,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -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);
@@ -68,18 +68,15 @@
     };
   }
 
-  private final AllUsersName allUsersName;
   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(
-      AllUsersName allUsersName,
       ExternalIds externalIds,
-      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
+      @Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
       @FanOutExecutor ExecutorService executor) {
-    this.allUsersName = allUsersName;
     this.externalIds = externalIds;
     this.byId = byId;
     this.executor = executor;
@@ -88,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);
     }
   }
@@ -98,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();
     }
   }
@@ -110,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));
@@ -132,7 +133,7 @@
     }
     for (Future<Optional<AccountState>> f : futures) {
       try {
-        f.get().ifPresent(s -> accountStates.put(s.getAccount().getId(), s));
+        f.get().ifPresent(s -> accountStates.put(s.account().id(), s));
       } catch (InterruptedException | ExecutionException e) {
         logger.atSevere().withCause(e).log("Cannot load AccountState");
       }
@@ -168,12 +169,12 @@
   }
 
   private AccountState missing(Account.Id accountId) {
-    Account account = new Account(accountId, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
     account.setActive(false);
-    return AccountState.forAccount(allUsersName, account);
+    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
@@ -182,10 +183,23 @@
     }
 
     @Override
-    public Optional<AccountState> load(Account.Id who) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading account %s", who)) {
-        return accounts.get(who);
+    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)
+            .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/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 06f7a08..5a1bb8a 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -21,12 +21,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -68,7 +68,7 @@
  *   <li>'account.config': Contains the account properties. Parsing and writing it is delegated to
  *       {@link AccountProperties}.
  *   <li>'preferences.config': Contains the preferences. Parsing and writing it is delegated to
- *       {@link Preferences}.
+ *       {@link StoredPreferences}.
  *   <li>'account.config': Contains the project watches. Parsing and writing it is delegated to
  *       {@link ProjectWatches}.
  * </ul>
@@ -85,7 +85,7 @@
   private Optional<AccountProperties> loadedAccountProperties;
   private Optional<ObjectId> externalIdsRev;
   private ProjectWatches projectWatches;
-  private Preferences preferences;
+  private StoredPreferences preferences;
   private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
   private List<ValidationError> validationErrors;
 
@@ -122,7 +122,7 @@
    * Returns the revision of the {@code refs/meta/external-ids} branch.
    *
    * <p>This revision can be used to load the external IDs of the loaded account lazily via {@link
-   * ExternalIds#byAccount(com.google.gerrit.reviewdb.client.Account.Id, ObjectId)}.
+   * ExternalIds#byAccount(com.google.gerrit.entities.Account.Id, ObjectId)}.
    *
    * @return revision of the {@code refs/meta/external-ids} branch, {@link Optional#empty()} if no
    *     {@code refs/meta/external-ids} branch exists
@@ -184,14 +184,14 @@
     checkLoaded();
     this.loadedAccountProperties =
         Optional.of(
-            new AccountProperties(account.getId(), account.getRegisteredOn(), new Config(), null));
+            new AccountProperties(account.id(), account.registeredOn(), new Config(), null));
     this.accountUpdate =
         Optional.of(
             InternalAccountUpdate.builder()
                 .setActive(account.isActive())
-                .setFullName(account.getFullName())
-                .setPreferredEmail(account.getPreferredEmail())
-                .setStatus(account.getStatus())
+                .setFullName(account.fullName())
+                .setPreferredEmail(account.preferredEmail())
+                .setStatus(account.status())
                 .build());
     return this;
   }
@@ -242,10 +242,10 @@
       projectWatches = new ProjectWatches(accountId, readConfig(ProjectWatches.WATCH_CONFIG), this);
 
       preferences =
-          new Preferences(
+          new StoredPreferences(
               accountId,
-              readConfig(Preferences.PREFERENCES_CONFIG),
-              Preferences.readDefaultConfig(allUsersName, repo),
+              readConfig(StoredPreferences.PREFERENCES_CONFIG),
+              StoredPreferences.readDefaultConfig(allUsersName, repo),
               this);
 
       projectWatches.parse();
@@ -256,8 +256,11 @@
       projectWatches = new ProjectWatches(accountId, new Config(), this);
 
       preferences =
-          new Preferences(
-              accountId, new Config(), Preferences.readDefaultConfig(allUsersName, repo), this);
+          new StoredPreferences(
+              accountId,
+              new Config(),
+              StoredPreferences.readDefaultConfig(allUsersName, repo),
+              this);
     }
 
     Ref externalIdsRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
@@ -331,7 +334,7 @@
     }
 
     saveConfig(
-        Preferences.PREFERENCES_CONFIG,
+        StoredPreferences.PREFERENCES_CONFIG,
         preferences.saveGeneralPreferences(
             accountUpdate.get().getGeneralPreferences(),
             accountUpdate.get().getDiffPreferences(),
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 4b8be81..f8a5c5c 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,11 +17,11 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -133,7 +133,7 @@
         new OtherUser() {
           @Override
           Account.Id getId() {
-            return otherUser.getAccount().getId();
+            return otherUser.account().id();
           }
 
           @Override
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index ac02322..b12e585 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -104,15 +104,15 @@
   }
 
   private boolean processAccount(AccountState accountState) {
-    if (!accountState.getUserName().isPresent()) {
+    if (!accountState.userName().isPresent()) {
       return false;
     }
 
-    String userName = accountState.getUserName().get();
+    String userName = accountState.userName().get();
     logger.atFine().log("processing account %s", userName);
     try {
-      if (realm.accountBelongsToRealm(accountState.getExternalIds()) && !realm.isActive(userName)) {
-        sif.deactivate(accountState.getAccount().getId());
+      if (realm.accountBelongsToRealm(accountState.externalIds()) && !realm.isActive(userName)) {
+        sif.deactivate(accountState.account().id());
         logger.atInfo().log("deactivated account %s", userName);
         return true;
       }
@@ -121,7 +121,7 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error deactivating account: %s (%s) %s",
-          userName, accountState.getAccount().getId(), e.getMessage());
+          userName, accountState.account().id(), e.getMessage());
     }
     return false;
   }
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index ee9265f..60c1678 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -45,7 +45,10 @@
     ID,
 
     /** The user-settable status of this account (e.g. busy, OOO, available) */
-    STATUS
+    STATUS,
+
+    /** The state of the account (e.g. active or inactive) */
+    STATE
   }
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
diff --git a/java/com/google/gerrit/server/account/AccountExternalIdCreator.java b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
index 8cf4ee0..dedd916 100644
--- a/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
+++ b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 4398d9e..a8e4194 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -17,8 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
@@ -41,6 +42,7 @@
               FillOptions.EMAIL,
               FillOptions.USERNAME,
               FillOptions.STATUS,
+              FillOptions.STATE,
               FillOptions.AVATARS));
 
   public interface Factory {
@@ -67,7 +69,8 @@
     provided = new ArrayList<>();
   }
 
-  public synchronized AccountInfo get(Account.Id id) {
+  @Nullable
+  public synchronized AccountInfo get(@Nullable Account.Id id) {
     if (id == null) {
       return null;
     }
@@ -95,7 +98,8 @@
     fill();
   }
 
-  public AccountInfo fillOne(Account.Id id) throws PermissionBackendException {
+  @Nullable
+  public AccountInfo fillOne(@Nullable Account.Id id) throws PermissionBackendException {
     AccountInfo info = get(id);
     fill();
     return info;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index e0fe5c5..345da81 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
@@ -25,11 +26,11 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
@@ -152,7 +153,7 @@
       }
 
       // Account exists
-      Optional<Account> act = updateAccountActiveStatus(who, accountState.get().getAccount());
+      Optional<Account> act = updateAccountActiveStatus(who, accountState.get().account());
       if (!act.isPresent()) {
         // The account was deleted since we checked for it last time. This should never happen
         // since we don't support deletion of accounts.
@@ -197,18 +198,18 @@
 
     if (authRequest.isActive()) {
       try {
-        setInactiveFlag.activate(account.getId());
+        setInactiveFlag.activate(account.id());
       } catch (Exception e) {
-        throw new AccountException("Unable to activate account " + account.getId(), e);
+        throw new AccountException("Unable to activate account " + account.id(), e);
       }
     } else {
       try {
-        setInactiveFlag.deactivate(account.getId());
+        setInactiveFlag.deactivate(account.id());
       } catch (Exception e) {
-        throw new AccountException("Unable to deactivate account " + account.getId(), e);
+        throw new AccountException("Unable to deactivate account " + account.id(), e);
       }
     }
-    return byIdCache.get(account.getId()).map(AccountState::getAccount);
+    return byIdCache.get(account.id()).map(AccountState::account);
   }
 
   private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
@@ -231,19 +232,19 @@
       checkEmailNotUsed(extId.accountId(), extIdWithNewEmail);
       accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
 
-      if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
+      if (oldEmail != null && oldEmail.equals(user.getAccount().preferredEmail())) {
         accountUpdates.add(u -> u.setPreferredEmail(newEmail));
       }
     }
 
     if (!Strings.isNullOrEmpty(who.getDisplayName())
-        && !Objects.equals(user.getAccount().getFullName(), who.getDisplayName())) {
+        && !Objects.equals(user.getAccount().fullName(), who.getDisplayName())) {
       if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
         accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
       } else {
         logger.atWarning().log(
             "Not changing already set display name '%s' to '%s'",
-            user.getAccount().getFullName(), who.getDisplayName());
+            user.getAccount().fullName(), who.getDisplayName());
       }
     }
 
@@ -273,7 +274,7 @@
 
   private AuthResult create(AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
-    Account.Id newId = new Account.Id(sequences.nextAccountId());
+    Account.Id newId = Account.id(sequences.nextAccountId());
     logger.atFine().log("Assigning new Id %s to account", newId);
 
     ExternalId extId =
@@ -337,7 +338,7 @@
       addGroupMember(adminGroupUuid, user);
     }
 
-    realm.onCreateAccount(who, accountState.getAccount());
+    realm.onCreateAccount(who, accountState.account());
     return new AuthResult(newId, extId.key(), true);
   }
 
@@ -426,7 +427,7 @@
               to,
               (a, u) -> {
                 u.addExternalId(newExtId);
-                if (who.getEmailAddress() != null && a.getAccount().getPreferredEmail() == null) {
+                if (who.getEmailAddress() != null && a.account().preferredEmail() == null) {
                   u.setPreferredEmail(who.getEmailAddress());
                 }
               });
@@ -454,8 +455,10 @@
             "Delete External IDs on Update Link",
             to,
             (a, u) -> {
-              Collection<ExternalId> filteredExtIdsByScheme =
-                  a.getExternalIds(who.getExternalIdKey().scheme());
+              Set<ExternalId> filteredExtIdsByScheme =
+                  a.externalIds().stream()
+                      .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
+                      .collect(toImmutableSet());
               if (filteredExtIdsByScheme.isEmpty()) {
                 return;
               }
@@ -517,9 +520,9 @@
             from,
             (a, u) -> {
               u.deleteExternalIds(extIds);
-              if (a.getAccount().getPreferredEmail() != null
+              if (a.account().preferredEmail() != null
                   && extIds.stream()
-                      .anyMatch(e -> a.getAccount().getPreferredEmail().equals(e.email()))) {
+                      .anyMatch(e -> a.account().preferredEmail().equals(e.email()))) {
                 u.setPreferredEmail(null);
               }
             });
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 6fcf56d..4f29b25 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -88,15 +88,16 @@
   }
 
   private void parse() {
-    account = new Account(accountId, registeredOn);
-    account.setActive(accountConfig.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
-    account.setFullName(get(accountConfig, KEY_FULL_NAME));
+    Account.Builder accountBuilder = Account.builder(accountId, registeredOn);
+    accountBuilder.setActive(accountConfig.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
+    accountBuilder.setFullName(get(accountConfig, KEY_FULL_NAME));
 
     String preferredEmail = get(accountConfig, KEY_PREFERRED_EMAIL);
-    account.setPreferredEmail(preferredEmail);
+    accountBuilder.setPreferredEmail(preferredEmail);
 
-    account.setStatus(get(accountConfig, KEY_STATUS));
-    account.setMetaId(metaId != null ? metaId.name() : null);
+    accountBuilder.setStatus(get(accountConfig, KEY_STATUS));
+    accountBuilder.setMetaId(metaId != null ? metaId.name() : null);
+    account = accountBuilder.build();
   }
 
   Config save(InternalAccountUpdate accountUpdate) {
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index f9945b5..988d871 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -26,9 +26,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -113,9 +113,9 @@
   }
 
   private static String formatForException(Result result, AccountState state) {
-    return state.getAccount().getId()
+    return state.account().id()
         + ": "
-        + state.getAccount().getNameEmail(result.accountResolver().anonymousCowardName);
+        + state.account().getNameEmail(result.accountResolver().anonymousCowardName);
   }
 
   public static boolean isSelf(String input) {
@@ -135,7 +135,7 @@
     }
 
     private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
-      TreeSet<AccountState> set = new TreeSet<>(comparing(a -> a.getAccount().getId().get()));
+      TreeSet<AccountState> set = new TreeSet<>(comparing(a -> a.account().id().get()));
       set.addAll(requireNonNull(list));
       return ImmutableList.copyOf(set);
     }
@@ -160,7 +160,7 @@
     }
 
     public ImmutableSet<Account.Id> asIdSet() {
-      return list.stream().map(a -> a.getAccount().getId()).collect(toImmutableSet());
+      return list.stream().map(a -> a.account().id()).collect(toImmutableSet());
     }
 
     public AccountState asUnique() throws UnresolvableAccountException {
@@ -192,7 +192,7 @@
         return self.get().asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).getAccount().getId(), requireNonNull(caller).getRealUser());
+          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -349,7 +349,7 @@
       String name = nameOrEmail.substring(0, lt - 1);
       ImmutableList<AccountState> nameMatches =
           allMatches.stream()
-              .filter(a -> name.equals(a.getAccount().getFullName()))
+              .filter(a -> name.equals(a.account().fullName()))
               .collect(toImmutableList());
       return !nameMatches.isEmpty() ? nameMatches.stream() : allMatches.stream();
     }
@@ -558,7 +558,7 @@
   }
 
   private Predicate<AccountState> accountActivityPredicate() {
-    return (AccountState accountState) -> accountState.getAccount().isActive();
+    return (AccountState accountState) -> accountState.account().isActive();
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index d09dff5..4fb69bd 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.TypeLiteral;
diff --git a/java/com/google/gerrit/server/account/AccountSshKey.java b/java/com/google/gerrit/server/account/AccountSshKey.java
index f132585..d2f8775 100644
--- a/java/com/google/gerrit/server/account/AccountSshKey.java
+++ b/java/com/google/gerrit/server/account/AccountSshKey.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.List;
 
 /** An SSH key approved for use by an {@link Account}. */
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 1854dc1..a270a76 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -14,34 +14,23 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.base.Function;
+import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
-import org.apache.commons.codec.DecoderException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -51,25 +40,19 @@
  * <p>Most callers should not construct AccountStates directly but rather lookup accounts via the
  * account cache (see {@link AccountCache#get(Account.Id)}).
  */
-public class AccountState {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      a -> a.getAccount().getId();
-
+@AutoValue
+public abstract class AccountState {
   /**
    * Creates an AccountState from the given account config.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      AllUsersName allUsersName, ExternalIds externalIds, AccountConfig accountConfig)
-      throws IOException {
-    return fromAccountConfig(allUsersName, externalIds, accountConfig, null);
+      ExternalIds externalIds, AccountConfig accountConfig) throws IOException {
+    return fromAccountConfig(externalIds, accountConfig, null);
   }
 
   /**
@@ -82,7 +65,6 @@
    * updated the revision of the external IDs branch in account config is outdated. Hence after
    * updating external IDs the external ID notes must be provided.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
    * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
@@ -90,10 +72,7 @@
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      AccountConfig accountConfig,
-      @Nullable ExternalIdNotes extIdNotes)
+      ExternalIds externalIds, AccountConfig accountConfig, @Nullable ExternalIdNotes extIdNotes)
       throws IOException {
     if (!accountConfig.getLoadedAccount().isPresent()) {
       return Optional.empty();
@@ -106,22 +85,25 @@
             : accountConfig.getExternalIdsRev();
     ImmutableSet<ExternalId> extIds =
         extIdsRev.isPresent()
-            ? ImmutableSet.copyOf(externalIds.byAccount(account.getId(), extIdsRev.get()))
+            ? ImmutableSet.copyOf(externalIds.byAccount(account.id(), extIdsRev.get()))
             : ImmutableSet.of();
 
     // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
     // an open Repository instance.
     ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
         accountConfig.getProjectWatches();
-    GeneralPreferencesInfo generalPreferences = accountConfig.getGeneralPreferences();
-    DiffPreferencesInfo diffPreferences = accountConfig.getDiffPreferences();
-    EditPreferencesInfo editPreferences = accountConfig.getEditPreferences();
+    Preferences.General generalPreferences =
+        Preferences.General.fromInfo(accountConfig.getGeneralPreferences());
+    Preferences.Diff diffPreferences =
+        Preferences.Diff.fromInfo(accountConfig.getDiffPreferences());
+    Preferences.Edit editPreferences =
+        Preferences.Edit.fromInfo(accountConfig.getEditPreferences());
 
     return Optional.of(
-        new AccountState(
-            allUsersName,
+        new AutoValue_AccountState(
             account,
             extIds,
+            ExternalId.getUserName(extIds),
             projectWatches,
             generalPreferences,
             diffPreferences,
@@ -132,71 +114,35 @@
    * Creates an AccountState for a given account with no external IDs, no project watches and
    * default preferences.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param account the account
    * @return the account state
    */
-  public static AccountState forAccount(AllUsersName allUsersName, Account account) {
-    return forAccount(allUsersName, account, ImmutableSet.of());
+  public static AccountState forAccount(Account account) {
+    return forAccount(account, ImmutableSet.of());
   }
 
   /**
    * Creates an AccountState for a given account with no project watches and default preferences.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param account the account
    * @param extIds the external IDs
    * @return the account state
    */
-  public static AccountState forAccount(
-      AllUsersName allUsersName, Account account, Collection<ExternalId> extIds) {
-    return new AccountState(
-        allUsersName,
+  public static AccountState forAccount(Account account, Collection<ExternalId> extIds) {
+    return new AutoValue_AccountState(
         account,
         ImmutableSet.copyOf(extIds),
+        ExternalId.getUserName(extIds),
         ImmutableMap.of(),
-        GeneralPreferencesInfo.defaults(),
-        DiffPreferencesInfo.defaults(),
-        EditPreferencesInfo.defaults());
-  }
-
-  private final AllUsersName allUsersName;
-  private final Account account;
-  private final ImmutableSet<ExternalId> externalIds;
-  private final Optional<String> userName;
-  private final ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches;
-  private final GeneralPreferencesInfo generalPreferences;
-  private final DiffPreferencesInfo diffPreferences;
-  private final EditPreferencesInfo editPreferences;
-  private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
-
-  private AccountState(
-      AllUsersName allUsersName,
-      Account account,
-      ImmutableSet<ExternalId> externalIds,
-      ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches,
-      GeneralPreferencesInfo generalPreferences,
-      DiffPreferencesInfo diffPreferences,
-      EditPreferencesInfo editPreferences) {
-    this.allUsersName = allUsersName;
-    this.account = account;
-    this.externalIds = externalIds;
-    this.userName = ExternalId.getUserName(externalIds);
-    this.projectWatches = projectWatches;
-    this.generalPreferences = generalPreferences;
-    this.diffPreferences = diffPreferences;
-    this.editPreferences = editPreferences;
-  }
-
-  public AllUsersName getAllUsersNameForIndexing() {
-    return allUsersName;
+        Preferences.General.fromInfo(GeneralPreferencesInfo.defaults()),
+        Preferences.Diff.fromInfo(DiffPreferencesInfo.defaults()),
+        Preferences.Edit.fromInfo(EditPreferencesInfo.defaults()));
   }
 
   /** Get the cached account metadata. */
-  public Account getAccount() {
-    return account;
-  }
-
+  public abstract Account account();
+  /** The external identities that identify the account holder. */
+  public abstract ImmutableSet<ExternalId> externalIds();
   /**
    * Get the username, if one has been declared for this user.
    *
@@ -205,122 +151,36 @@
    * @return the username, {@link Optional#empty()} if the user has no username, or if the username
    *     is empty
    */
-  public Optional<String> getUserName() {
-    return userName;
-  }
-
-  public boolean checkPassword(@Nullable String password, String username) {
-    if (password == null) {
-      return false;
-    }
-    for (ExternalId id : getExternalIds()) {
-      // Only process the "username:$USER" entry, which is unique.
-      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
-        continue;
-      }
-
-      String hashedStr = id.password();
-      if (!Strings.isNullOrEmpty(hashedStr)) {
-        try {
-          return HashedPassword.decode(hashedStr).checkPassword(password);
-        } catch (DecoderException e) {
-          logger.atSevere().log("DecoderException for user %s: %s ", username, e.getMessage());
-          return false;
-        }
-      }
-    }
-    return false;
-  }
-
-  /** The external identities that identify the account holder. */
-  public ImmutableSet<ExternalId> getExternalIds() {
-    return externalIds;
-  }
-
-  /** The external identities that identify the account holder that match the given scheme. */
-  public ImmutableSet<ExternalId> getExternalIds(String scheme) {
-    return externalIds.stream().filter(e -> e.key().isScheme(scheme)).collect(toImmutableSet());
-  }
-
+  public abstract Optional<String> userName();
   /** The project watches of the account. */
-  public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
-    return projectWatches;
-  }
+  public abstract ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches();
+  /** The general preferences of the account. */
 
   /** The general preferences of the account. */
-  public GeneralPreferencesInfo getGeneralPreferences() {
-    return generalPreferences;
+  public GeneralPreferencesInfo generalPreferences() {
+    return immutableGeneralPreferences().toInfo();
   }
 
   /** The diff preferences of the account. */
-  public DiffPreferencesInfo getDiffPreferences() {
-    return diffPreferences;
+  public DiffPreferencesInfo diffPreferences() {
+    return immutableDiffPreferences().toInfo();
   }
 
   /** The edit preferences of the account. */
-  public EditPreferencesInfo getEditPreferences() {
-    return editPreferences;
-  }
-
-  /**
-   * Lookup a previously stored property.
-   *
-   * <p>All properties are automatically cleared when the account cache invalidates the {@code
-   * AccountState}. This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @return previously stored value, or {@code null}.
-   */
-  @Nullable
-  public <T> T get(PropertyKey<T> key) {
-    Cache<PropertyKey<Object>, Object> p = properties(false);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) p.getIfPresent(key);
-      return value;
-    }
-    return null;
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * <p>This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {
-    Cache<PropertyKey<Object>, Object> p = properties(value != null);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      PropertyKey<Object> k = (PropertyKey<Object>) key;
-      if (value != null) {
-        p.put(k, value);
-      } else {
-        p.invalidate(k);
-      }
-    }
-  }
-
-  private synchronized Cache<PropertyKey<Object>, Object> properties(boolean allocate) {
-    if (properties == null && allocate) {
-      properties =
-          CacheBuilder.newBuilder()
-              .concurrencyLevel(1)
-              .initialCapacity(16)
-              // Use weakKeys to ensure plugins that garbage collect will also
-              // eventually release data held in any still live AccountState.
-              .weakKeys()
-              .build();
-    }
-    return properties;
+  public EditPreferencesInfo editPreferences() {
+    return immutableEditPreferences().toInfo();
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
-    h.addValue(getAccount().getId());
+    h.addValue(account().id());
     return h.toString();
   }
+
+  protected abstract Preferences.General immutableGeneralPreferences();
+
+  protected abstract Preferences.Diff immutableDiffPreferences();
+
+  protected abstract Preferences.Edit immutableEditPreferences();
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index f3758bf..8136631 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -19,8 +19,8 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -134,9 +134,7 @@
   private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     return AccountState.fromAccountConfig(
-        allUsersName,
-        externalIds,
-        new AccountConfig(accountId, allUsersName, allUsersRepository).load());
+        externalIds, new AccountConfig(accountId, allUsersName, allUsersRepository).load());
   }
 
   public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
diff --git a/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
index b43d86c..db350c6 100644
--- a/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -35,14 +35,14 @@
     List<ConsistencyProblemInfo> problems = new ArrayList<>();
 
     for (AccountState accountState : accounts.all()) {
-      Account account = accountState.getAccount();
-      if (account.getPreferredEmail() != null) {
-        if (accountState.getExternalIds().stream()
-            .noneMatch(e -> account.getPreferredEmail().equals(e.email()))) {
+      Account account = accountState.account();
+      if (account.preferredEmail() != null) {
+        if (accountState.externalIds().stream()
+            .noneMatch(e -> account.preferredEmail().equals(e.email()))) {
           addError(
               String.format(
                   "Account '%s' has no external ID for its preferred email '%s'",
-                  account.getId().get(), account.getPreferredEmail()),
+                  account.id().get(), account.preferredEmail()),
               problems);
         }
       }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 20a1c97..1caee58 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -24,11 +24,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
@@ -88,9 +88,9 @@
  * The timestamp of the first commit on a user branch denotes the registration date. The initial
  * commit on the user branch may be empty (since having an 'account.config' is optional). See {@link
  * AccountConfig} for details of the 'account.config' file format. In addition the user branch can
- * contain a 'preferences.config' config file to store preferences (see {@link Preferences}) and a
- * 'watch.config' config file to store project watches (see {@link ProjectWatches}). External IDs
- * are stored separately in the {@code refs/meta/external-ids} notes branch (see {@link
+ * contain a 'preferences.config' config file to store preferences (see {@link StoredPreferences})
+ * and a 'watch.config' config file to store project watches (see {@link ProjectWatches}). External
+ * IDs are stored separately in the {@code refs/meta/external-ids} notes branch (see {@link
  * ExternalIdNotes}).
  *
  * <p>On updating an account the account is evicted from the account cache and reindexed. The
@@ -321,7 +321,7 @@
               AccountConfig accountConfig = read(r, accountId);
               Account account =
                   accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
-              AccountState accountState = AccountState.forAccount(allUsersName, account);
+              AccountState accountState = AccountState.forAccount(account);
               InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
               updater.update(accountState, updateBuilder);
 
@@ -330,7 +330,7 @@
               ExternalIdNotes extIdNotes =
                   createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
               UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(allUsersName, externalIds, message, accountConfig, extIdNotes);
+                  new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
               updatedAccounts.setCreated(true);
               return updatedAccounts;
             })
@@ -377,7 +377,7 @@
         r -> {
           AccountConfig accountConfig = read(r, accountId);
           Optional<AccountState> account =
-              AccountState.fromAccountConfig(allUsersName, externalIds, accountConfig);
+              AccountState.fromAccountConfig(externalIds, accountConfig);
           if (!account.isPresent()) {
             return null;
           }
@@ -390,7 +390,7 @@
           ExternalIdNotes extIdNotes =
               createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
           UpdatedAccount updatedAccounts =
-              new UpdatedAccount(allUsersName, externalIds, message, accountConfig, extIdNotes);
+              new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
           return updatedAccounts;
         });
   }
@@ -561,7 +561,6 @@
   }
 
   private static class UpdatedAccount {
-    private final AllUsersName allUsersName;
     private final ExternalIds externalIds;
     private final String message;
     private final AccountConfig accountConfig;
@@ -570,13 +569,11 @@
     private boolean created;
 
     private UpdatedAccount(
-        AllUsersName allUsersName,
         ExternalIds externalIds,
         String message,
         AccountConfig accountConfig,
         ExternalIdNotes extIdNotes) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.allUsersName = requireNonNull(allUsersName);
       this.externalIds = requireNonNull(externalIds);
       this.message = requireNonNull(message);
       this.accountConfig = requireNonNull(accountConfig);
@@ -592,8 +589,7 @@
     }
 
     public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(allUsersName, externalIds, accountConfig, extIdNotes)
-          .get();
+      return AccountState.fromAccountConfig(externalIds, accountConfig, extIdNotes).get();
     }
 
     public ExternalIdNotes getExternalIdNotes() {
diff --git a/java/com/google/gerrit/server/account/AuthResult.java b/java/com/google/gerrit/server/account/AuthResult.java
index 2b1bc96..1f89827 100644
--- a/java/com/google/gerrit/server/account/AuthResult.java
+++ b/java/com/google/gerrit/server/account/AuthResult.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
diff --git a/java/com/google/gerrit/server/account/AuthorizedKeys.java b/java/com/google/gerrit/server/account/AuthorizedKeys.java
index b392c18..203ac5c 100644
--- a/java/com/google/gerrit/server/account/AuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -16,7 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 5bcb84b..2a764cc 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Collection;
 
 public class CreateGroupArgs {
   private AccountGroup.NameKey groupName;
+  public AccountGroup.UUID uuid;
   public String groupDescription;
   public boolean visibleToAll;
   public AccountGroup.UUID ownerGroupUuid;
@@ -34,7 +35,7 @@
   }
 
   public void setGroupName(String n) {
-    groupName = n != null ? new AccountGroup.NameKey(n) : null;
+    groupName = n != null ? AccountGroup.nameKey(n) : null;
   }
 
   public void setGroupName(AccountGroup.NameKey n) {
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 33de2d2..329825f 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,10 +16,10 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 04e710a..15c1e25 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,8 +18,8 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
 import java.io.IOException;
@@ -28,10 +28,10 @@
 
 public class DestinationList extends TabFile {
   public static final String DIR_NAME = "destinations";
-  private SetMultimap<String, Branch.NameKey> destinations =
+  private SetMultimap<String, BranchNameKey> destinations =
       MultimapBuilder.hashKeys().hashSetValues().build();
 
-  public Set<Branch.NameKey> getDestinations(String label) {
+  public Set<BranchNameKey> getDestinations(String label) {
     return destinations.get(label);
   }
 
@@ -40,21 +40,21 @@
   }
 
   String asText(String label) {
-    Set<Branch.NameKey> dests = destinations.get(label);
+    Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
       return null;
     }
     List<Row> rows = Lists.newArrayListWithCapacity(dests.size());
-    for (Branch.NameKey dest : sort(dests)) {
-      rows.add(new Row(dest.get(), dest.getParentKey().get()));
+    for (BranchNameKey dest : sort(dests)) {
+      rows.add(new Row(dest.branch(), dest.project().get()));
     }
     return asText("Ref", "Project", rows);
   }
 
-  private static Set<Branch.NameKey> toSet(List<Row> destRows) {
-    Set<Branch.NameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
+  private static Set<BranchNameKey> toSet(List<Row> destRows) {
+    Set<BranchNameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
     for (Row row : destRows) {
-      dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left));
+      dests.add(BranchNameKey.create(Project.nameKey(row.right), row.left));
     }
     return dests;
   }
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 426d6ea..76c22cf 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -14,14 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Streams;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -32,6 +35,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Class to access accounts by email. */
 @Singleton
@@ -65,15 +73,20 @@
    * have no external ID for the preferred email. Having accounts with a preferred email that does
    * not exist as external ID is an inconsistency, but existing functionality relies on still
    * getting those accounts, which is why they are included. Accounts by preferred email are fetched
-   * from the account index.
+   * from the account index as a fallback for email addresses that could not be resolved using
+   * {@link ExternalIds}.
    *
    * @see #getAccountsFor(String...)
    */
   public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException {
-    return Streams.concat(
-            externalIds.byEmail(email).stream().map(ExternalId::accountId),
-            executeIndexQuery(() -> queryProvider.get().byPreferredEmail(email).stream())
-                .map(a -> a.getAccount().getId()))
+    ImmutableSet<Account.Id> accounts =
+        externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
+    if (!accounts.isEmpty()) {
+      return accounts;
+    }
+
+    return executeIndexQuery(() -> queryProvider.get().byPreferredEmail(email).stream())
+        .map(a -> a.account().id())
         .collect(toImmutableSet());
   }
 
@@ -84,12 +97,18 @@
    */
   public ImmutableSetMultimap<String, Account.Id> getAccountsFor(String... emails)
       throws IOException {
-    ImmutableSetMultimap.Builder<String, Account.Id> builder = ImmutableSetMultimap.builder();
+    SetMultimap<String, Account.Id> result =
+        MultimapBuilder.hashKeys(emails.length).hashSetValues(1).build();
     externalIds.byEmails(emails).entries().stream()
-        .forEach(e -> builder.put(e.getKey(), e.getValue().accountId()));
-    executeIndexQuery(() -> queryProvider.get().byPreferredEmail(emails).entries().stream())
-        .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().getId()));
-    return builder.build();
+        .forEach(e -> result.put(e.getKey(), e.getValue().accountId()));
+    List<String> emailsToBackfill =
+        Arrays.stream(emails).filter(e -> !result.containsKey(e)).collect(toImmutableList());
+    if (!emailsToBackfill.isEmpty()) {
+      executeIndexQuery(
+              () -> queryProvider.get().byPreferredEmail(emailsToBackfill).entries().stream())
+          .forEach(e -> result.put(e.getKey(), e.getValue().account().id()));
+    }
+    return ImmutableSetMultimap.copyOf(result);
   }
 
   /**
@@ -102,6 +121,24 @@
     return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
   }
 
+  public UserIdentity toUserIdentity(PersonIdent who) throws IOException {
+    UserIdentity u = new UserIdentity();
+    u.setName(who.getName());
+    u.setEmail(who.getEmailAddress());
+    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setTimeZone(who.getTimeZoneOffset());
+
+    // If only one account has access to this email address, select it
+    // as the identity of the user.
+    //
+    Set<Account.Id> a = getAccountFor(u.getEmail());
+    if (a.size() == 1) {
+      u.setAccount(a.iterator().next());
+    }
+
+    return u;
+  }
+
   private <T> T executeIndexQuery(Action<T> action) {
     try {
       return retryHelper.execute(
diff --git a/java/com/google/gerrit/server/account/FakeRealm.java b/java/com/google/gerrit/server/account/FakeRealm.java
index a53f64e..30274e0 100644
--- a/java/com/google/gerrit/server/account/FakeRealm.java
+++ b/java/com/google/gerrit/server/account/FakeRealm.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.reviewdb.client.Account;
 
 /** Fake implementation of {@link Realm} that does not communicate. */
 public class FakeRealm extends AbstractRealm {
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 2d46260..3a874bb 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 8133d9c..90d3aa9 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.util.Optional;
 
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index c85e2df..fe22028 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -17,10 +17,11 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
@@ -149,7 +150,9 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group %s by ID", key)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by ID", Metadata.builder().groupId(key.get()).build())) {
         return groupQueryProvider.get().byId(key);
       }
     }
@@ -165,8 +168,10 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group '%s' by name", name)) {
-        return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by name", Metadata.builder().groupName(name).build())) {
+        return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
       }
     }
   }
@@ -181,8 +186,10 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group %s by UUID", uuid)) {
-        return groups.getGroup(new AccountGroup.UUID(uuid));
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by UUID", Metadata.builder().groupUuid(uuid).build())) {
+        return groups.getGroup(AccountGroup.uuid(uuid));
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index b3e6739..2228525 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCache.java b/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 612730b..6547619 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Collection;
 
 /** Tracks group inclusions in memory for efficient access. */
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index c27d6c3..7883b11 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -22,11 +22,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
@@ -152,7 +153,9 @@
 
     @Override
     public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) {
-      try (TraceTimer timer = TraceContext.newTimer("Loading groups with member %s", memberId)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading groups with member", Metadata.builder().accountId(memberId.get()).build())) {
         return groupQueryProvider.get().byMember(memberId).stream()
             .map(InternalGroup::getGroupUUID)
             .collect(toImmutableSet());
@@ -171,7 +174,9 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) {
-      try (TraceTimer timer = TraceContext.newTimer("Loading parent groups of %s", key)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading parent groups", Metadata.builder().groupUuid(key.get()).build())) {
         return groupQueryProvider.get().bySubgroup(key).stream()
             .map(InternalGroup::getGroupUUID)
             .collect(toImmutableList());
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index d7e97ba..c2b935b 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -19,9 +19,9 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -131,7 +131,7 @@
             .filter(groupControl::canSeeMember)
             .map(accountCache::get)
             .flatMap(Streams::stream)
-            .map(AccountState::getAccount)
+            .map(AccountState::account)
             .collect(toImmutableSet());
 
     Set<Account> indirectMembers = new HashSet<>();
diff --git a/java/com/google/gerrit/server/account/GroupMembership.java b/java/com/google/gerrit/server/account/GroupMembership.java
index b59b989..e051794 100644
--- a/java/com/google/gerrit/server/account/GroupMembership.java
+++ b/java/com/google/gerrit/server/account/GroupMembership.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Collections;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
index a7b32a1..ac83482 100644
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/java/com/google/gerrit/server/account/GroupUUID.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import java.security.MessageDigest;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -26,7 +26,7 @@
     md.update(Constants.encode("group " + groupName + "\n"));
     md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
     md.update(Constants.encode(String.valueOf(Math.random())));
-    return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
+    return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 
   private GroupUUID() {}
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index bffa3ce..64a4495 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -22,7 +22,6 @@
 import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
 import java.util.List;
-import org.apache.commons.codec.DecoderException;
 import org.bouncycastle.crypto.generators.BCrypt;
 import org.bouncycastle.util.Arrays;
 
@@ -39,6 +38,14 @@
   // for a high cost.
   private static final int DEFAULT_COST = 4;
 
+  public static class DecoderException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public DecoderException(String message) {
+      super(message);
+    }
+  }
+
   /**
    * decodes a hashed password encoded with {@link #encode}.
    *
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index b6969ac..6dc7976 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index ce97ff9..e27b77c 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -20,11 +20,11 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -98,15 +98,14 @@
 
     Set<FillOptions> fillOptionsWithoutSecondaryEmails =
         Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
-    Set<Account.Id> ids =
-        Streams.stream(in).map(a -> new Account.Id(a._accountId)).collect(toSet());
+    Set<Account.Id> ids = Streams.stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
     Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
-      Account.Id id = new Account.Id(info._accountId);
+      Account.Id id = Account.id(info._accountId);
       AccountState state = accountStates.get(id);
       if (state != null) {
         if (!options.contains(FillOptions.SECONDARY_EMAILS)
-            || Objects.equals(currentUserId, state.getAccount().getId())
+            || Objects.equals(currentUserId, state.account().id())
             || canModifyAccount) {
           fill(info, accountStates.get(id), options);
         } else {
@@ -121,50 +120,53 @@
   }
 
   private void fill(AccountInfo info, AccountState accountState, Set<FillOptions> options) {
-    Account account = accountState.getAccount();
+    Account account = accountState.account();
     if (options.contains(FillOptions.ID)) {
-      info._accountId = account.getId().get();
+      info._accountId = account.id().get();
     } else {
       // Was previously set to look up account for filling.
       info._accountId = null;
     }
     if (options.contains(FillOptions.NAME)) {
-      info.name = Strings.emptyToNull(account.getFullName());
+      info.name = Strings.emptyToNull(account.fullName());
       if (info.name == null) {
-        info.name = accountState.getUserName().orElse(null);
+        info.name = accountState.userName().orElse(null);
       }
     }
     if (options.contains(FillOptions.EMAIL)) {
-      info.email = account.getPreferredEmail();
+      info.email = account.preferredEmail();
     }
     if (options.contains(FillOptions.SECONDARY_EMAILS)) {
-      info.secondaryEmails = getSecondaryEmails(account, accountState.getExternalIds());
+      info.secondaryEmails = getSecondaryEmails(account, accountState.externalIds());
     }
     if (options.contains(FillOptions.USERNAME)) {
-      info.username = accountState.getUserName().orElse(null);
+      info.username = accountState.userName().orElse(null);
     }
 
     if (options.contains(FillOptions.STATUS)) {
-      info.status = account.getStatus();
+      info.status = account.status();
+    }
+
+    if (options.contains(FillOptions.STATE)) {
+      info.inactive = account.inactive() ? true : null;
     }
 
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
-        info.avatars = new ArrayList<>(3);
-        IdentifiedUser user = userFactory.create(account.getId());
+        info.avatars = new ArrayList<>();
+        IdentifiedUser user = userFactory.create(account.id());
 
-        // GWT UI uses DEFAULT_SIZE (26px).
+        // PolyGerrit UI uses the following sizes for avatars:
+        // - 32px for avatars next to names e.g. on the dashboard. This is also Gerrit's default.
+        // - 56px for the user's own avatar in the menu
+        // - 100ox for other user's avatars on dashboards
+        // - 120px for the user's own profile settings page
         addAvatar(ap, info, user, AvatarInfo.DEFAULT_SIZE);
-
-        // PolyGerrit UI prefers 32px and 100px.
         if (!info.avatars.isEmpty()) {
-          if (32 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 32);
-          }
-          if (100 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 100);
-          }
+          addAvatar(ap, info, user, 56);
+          addAvatar(ap, info, user, 100);
+          addAvatar(ap, info, user, 120);
         }
       }
     }
@@ -172,7 +174,7 @@
 
   public List<String> getSecondaryEmails(Account account, Collection<ExternalId> externalIds) {
     return ExternalId.getEmails(externalIds)
-        .filter(e -> !e.equals(account.getPreferredEmail()))
+        .filter(e -> !e.equals(account.preferredEmail()))
         .sorted()
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
index c778fca..cf77a75 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -18,10 +18,10 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index ea6eb87..ddd3da2 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
diff --git a/java/com/google/gerrit/server/account/ListGroupMembership.java b/java/com/google/gerrit/server/account/ListGroupMembership.java
index 60e7345..0f4fb78 100644
--- a/java/com/google/gerrit/server/account/ListGroupMembership.java
+++ b/java/com/google/gerrit/server/account/ListGroupMembership.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.Set;
 
 /** GroupMembership over an explicit list. */
diff --git a/java/com/google/gerrit/server/account/Preferences.java b/java/com/google/gerrit/server/account/Preferences.java
index cd849b8..ece610b 100644
--- a/java/com/google/gerrit/server/account/Preferences.java
+++ b/java/com/google/gerrit/server/account/Preferences.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2018 The Android Open Source Project
+// 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.
@@ -11,566 +11,419 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.server.account;
 
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
 
-/**
- * Parses/writes preferences from/to a {@link Config} file.
- *
- * <p>This is a low-level API. Read/write of preferences in a user branch should be done through
- * {@link AccountsUpdate} or {@link AccountConfig}.
- *
- * <p>The config file has separate sections for general, diff and edit preferences:
- *
- * <pre>
- *   [general]
- *     showSiteHeader = false
- *   [diff]
- *     hideTopMenu = true
- *   [edit]
- *     lineLength = 80
- * </pre>
- *
- * <p>The parameter names match the names that are used in the preferences REST API.
- *
- * <p>If the preference is omitted in the config file, then the default value for the preference is
- * used.
- *
- * <p>Defaults for preferences that apply for all accounts can be configured in the {@code
- * refs/users/default} branch in the {@code All-Users} repository. The config for the default
- * preferences must be provided to this class so that it can read default values from it.
- *
- * <p>The preferences are lazily parsed.
- */
-public class Preferences {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+@AutoValue
+public abstract class Preferences {
+  @AutoValue
+  public abstract static class General {
+    public abstract Optional<Integer> changesPerPage();
 
-  public static final String PREFERENCES_CONFIG = "preferences.config";
+    public abstract Optional<String> downloadScheme();
 
-  private final Account.Id accountId;
-  private final Config cfg;
-  private final Config defaultCfg;
-  private final ValidationError.Sink validationErrorSink;
+    public abstract Optional<DateFormat> dateFormat();
 
-  private GeneralPreferencesInfo generalPreferences;
-  private DiffPreferencesInfo diffPreferences;
-  private EditPreferencesInfo editPreferences;
+    public abstract Optional<TimeFormat> timeFormat();
 
-  Preferences(
-      Account.Id accountId,
-      Config cfg,
-      Config defaultCfg,
-      ValidationError.Sink validationErrorSink) {
-    this.accountId = requireNonNull(accountId, "accountId");
-    this.cfg = requireNonNull(cfg, "cfg");
-    this.defaultCfg = requireNonNull(defaultCfg, "defaultCfg");
-    this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
-  }
+    public abstract Optional<Boolean> expandInlineDiffs();
 
-  public GeneralPreferencesInfo getGeneralPreferences() {
-    if (generalPreferences == null) {
-      parse();
-    }
-    return generalPreferences;
-  }
+    public abstract Optional<Boolean> highlightAssigneeInChangeTable();
 
-  public DiffPreferencesInfo getDiffPreferences() {
-    if (diffPreferences == null) {
-      parse();
-    }
-    return diffPreferences;
-  }
+    public abstract Optional<Boolean> relativeDateInChangeTable();
 
-  public EditPreferencesInfo getEditPreferences() {
-    if (editPreferences == null) {
-      parse();
-    }
-    return editPreferences;
-  }
+    public abstract Optional<DiffView> diffView();
 
-  public void parse() {
-    generalPreferences = parseGeneralPreferences(null);
-    diffPreferences = parseDiffPreferences(null);
-    editPreferences = parseEditPreferences(null);
-  }
+    public abstract Optional<Boolean> sizeBarInChangeTable();
 
-  public Config saveGeneralPreferences(
-      Optional<GeneralPreferencesInfo> generalPreferencesInput,
-      Optional<DiffPreferencesInfo> diffPreferencesInput,
-      Optional<EditPreferencesInfo> editPreferencesInput)
-      throws ConfigInvalidException {
-    if (generalPreferencesInput.isPresent()) {
-      GeneralPreferencesInfo mergedGeneralPreferencesInput =
-          parseGeneralPreferences(generalPreferencesInput.get());
+    public abstract Optional<Boolean> legacycidInChangeTable();
 
-      storeSection(
-          cfg,
-          UserConfigSections.GENERAL,
-          null,
-          mergedGeneralPreferencesInput,
-          parseDefaultGeneralPreferences(defaultCfg, null));
-      setChangeTable(cfg, mergedGeneralPreferencesInput.changeTable);
-      setMy(cfg, mergedGeneralPreferencesInput.my);
+    public abstract Optional<Boolean> muteCommonPathPrefixes();
 
-      // evict the cached general preferences
-      this.generalPreferences = null;
+    public abstract Optional<Boolean> signedOffBy();
+
+    public abstract Optional<EmailStrategy> emailStrategy();
+
+    public abstract Optional<EmailFormat> emailFormat();
+
+    public abstract Optional<DefaultBase> defaultBaseForMerges();
+
+    public abstract Optional<Boolean> publishCommentsOnPush();
+
+    public abstract Optional<Boolean> workInProgressByDefault();
+
+    public abstract Optional<ImmutableList<MenuItem>> my();
+
+    public abstract Optional<ImmutableList<String>> changeTable();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder changesPerPage(@Nullable Integer val);
+
+      abstract Builder downloadScheme(@Nullable String val);
+
+      abstract Builder dateFormat(@Nullable DateFormat val);
+
+      abstract Builder timeFormat(@Nullable TimeFormat val);
+
+      abstract Builder expandInlineDiffs(@Nullable Boolean val);
+
+      abstract Builder highlightAssigneeInChangeTable(@Nullable Boolean val);
+
+      abstract Builder relativeDateInChangeTable(@Nullable Boolean val);
+
+      abstract Builder diffView(@Nullable DiffView val);
+
+      abstract Builder sizeBarInChangeTable(@Nullable Boolean val);
+
+      abstract Builder legacycidInChangeTable(@Nullable Boolean val);
+
+      abstract Builder muteCommonPathPrefixes(@Nullable Boolean val);
+
+      abstract Builder signedOffBy(@Nullable Boolean val);
+
+      abstract Builder emailStrategy(@Nullable EmailStrategy val);
+
+      abstract Builder emailFormat(@Nullable EmailFormat val);
+
+      abstract Builder defaultBaseForMerges(@Nullable DefaultBase val);
+
+      abstract Builder publishCommentsOnPush(@Nullable Boolean val);
+
+      abstract Builder workInProgressByDefault(@Nullable Boolean val);
+
+      abstract Builder my(@Nullable ImmutableList<MenuItem> val);
+
+      abstract Builder changeTable(@Nullable ImmutableList<String> val);
+
+      abstract General build();
     }
 
-    if (diffPreferencesInput.isPresent()) {
-      DiffPreferencesInfo mergedDiffPreferencesInput =
-          parseDiffPreferences(diffPreferencesInput.get());
-
-      storeSection(
-          cfg,
-          UserConfigSections.DIFF,
-          null,
-          mergedDiffPreferencesInput,
-          parseDefaultDiffPreferences(defaultCfg, null));
-
-      // evict the cached diff preferences
-      this.diffPreferences = null;
+    public static General fromInfo(GeneralPreferencesInfo info) {
+      return (new AutoValue_Preferences_General.Builder())
+          .changesPerPage(info.changesPerPage)
+          .downloadScheme(info.downloadScheme)
+          .dateFormat(info.dateFormat)
+          .timeFormat(info.timeFormat)
+          .expandInlineDiffs(info.expandInlineDiffs)
+          .highlightAssigneeInChangeTable(info.highlightAssigneeInChangeTable)
+          .relativeDateInChangeTable(info.relativeDateInChangeTable)
+          .diffView(info.diffView)
+          .sizeBarInChangeTable(info.sizeBarInChangeTable)
+          .legacycidInChangeTable(info.legacycidInChangeTable)
+          .muteCommonPathPrefixes(info.muteCommonPathPrefixes)
+          .signedOffBy(info.signedOffBy)
+          .emailStrategy(info.emailStrategy)
+          .emailFormat(info.emailFormat)
+          .defaultBaseForMerges(info.defaultBaseForMerges)
+          .publishCommentsOnPush(info.publishCommentsOnPush)
+          .workInProgressByDefault(info.workInProgressByDefault)
+          .my(info.my == null ? null : ImmutableList.copyOf(info.my))
+          .changeTable(info.changeTable == null ? null : ImmutableList.copyOf(info.changeTable))
+          .build();
     }
 
-    if (editPreferencesInput.isPresent()) {
-      EditPreferencesInfo mergedEditPreferencesInput =
-          parseEditPreferences(editPreferencesInput.get());
-
-      storeSection(
-          cfg,
-          UserConfigSections.EDIT,
-          null,
-          mergedEditPreferencesInput,
-          parseDefaultEditPreferences(defaultCfg, null));
-
-      // evict the cached edit preferences
-      this.editPreferences = null;
-    }
-
-    return cfg;
-  }
-
-  private GeneralPreferencesInfo parseGeneralPreferences(@Nullable GeneralPreferencesInfo input) {
-    try {
-      return parseGeneralPreferences(cfg, defaultCfg, input);
-    } catch (ConfigInvalidException e) {
-      validationErrorSink.error(
-          new ValidationError(
-              PREFERENCES_CONFIG,
-              String.format(
-                  "Invalid general preferences for account %d: %s",
-                  accountId.get(), e.getMessage())));
-      return new GeneralPreferencesInfo();
+    public GeneralPreferencesInfo toInfo() {
+      GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+      info.changesPerPage = changesPerPage().orElse(null);
+      info.downloadScheme = downloadScheme().orElse(null);
+      info.dateFormat = dateFormat().orElse(null);
+      info.timeFormat = timeFormat().orElse(null);
+      info.expandInlineDiffs = expandInlineDiffs().orElse(null);
+      info.highlightAssigneeInChangeTable = highlightAssigneeInChangeTable().orElse(null);
+      info.relativeDateInChangeTable = relativeDateInChangeTable().orElse(null);
+      info.diffView = diffView().orElse(null);
+      info.sizeBarInChangeTable = sizeBarInChangeTable().orElse(null);
+      info.legacycidInChangeTable = legacycidInChangeTable().orElse(null);
+      info.muteCommonPathPrefixes = muteCommonPathPrefixes().orElse(null);
+      info.signedOffBy = signedOffBy().orElse(null);
+      info.emailStrategy = emailStrategy().orElse(null);
+      info.emailFormat = emailFormat().orElse(null);
+      info.defaultBaseForMerges = defaultBaseForMerges().orElse(null);
+      info.publishCommentsOnPush = publishCommentsOnPush().orElse(null);
+      info.workInProgressByDefault = workInProgressByDefault().orElse(null);
+      info.my = my().orElse(null);
+      info.changeTable = changeTable().orElse(null);
+      return info;
     }
   }
 
-  private DiffPreferencesInfo parseDiffPreferences(@Nullable DiffPreferencesInfo input) {
-    try {
-      return parseDiffPreferences(cfg, defaultCfg, input);
-    } catch (ConfigInvalidException e) {
-      validationErrorSink.error(
-          new ValidationError(
-              PREFERENCES_CONFIG,
-              String.format(
-                  "Invalid diff preferences for account %d: %s", accountId.get(), e.getMessage())));
-      return new DiffPreferencesInfo();
+  @AutoValue
+  public abstract static class Edit {
+    public abstract Optional<Integer> tabSize();
+
+    public abstract Optional<Integer> lineLength();
+
+    public abstract Optional<Integer> indentUnit();
+
+    public abstract Optional<Integer> cursorBlinkRate();
+
+    public abstract Optional<Boolean> hideTopMenu();
+
+    public abstract Optional<Boolean> showTabs();
+
+    public abstract Optional<Boolean> showWhitespaceErrors();
+
+    public abstract Optional<Boolean> syntaxHighlighting();
+
+    public abstract Optional<Boolean> hideLineNumbers();
+
+    public abstract Optional<Boolean> matchBrackets();
+
+    public abstract Optional<Boolean> lineWrapping();
+
+    public abstract Optional<Boolean> indentWithTabs();
+
+    public abstract Optional<Boolean> autoCloseBrackets();
+
+    public abstract Optional<Boolean> showBase();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder tabSize(@Nullable Integer val);
+
+      abstract Builder lineLength(@Nullable Integer val);
+
+      abstract Builder indentUnit(@Nullable Integer val);
+
+      abstract Builder cursorBlinkRate(@Nullable Integer val);
+
+      abstract Builder hideTopMenu(@Nullable Boolean val);
+
+      abstract Builder showTabs(@Nullable Boolean val);
+
+      abstract Builder showWhitespaceErrors(@Nullable Boolean val);
+
+      abstract Builder syntaxHighlighting(@Nullable Boolean val);
+
+      abstract Builder hideLineNumbers(@Nullable Boolean val);
+
+      abstract Builder matchBrackets(@Nullable Boolean val);
+
+      abstract Builder lineWrapping(@Nullable Boolean val);
+
+      abstract Builder indentWithTabs(@Nullable Boolean val);
+
+      abstract Builder autoCloseBrackets(@Nullable Boolean val);
+
+      abstract Builder showBase(@Nullable Boolean val);
+
+      abstract Edit build();
+    }
+
+    public static Edit fromInfo(EditPreferencesInfo info) {
+      return (new AutoValue_Preferences_Edit.Builder())
+          .tabSize(info.tabSize)
+          .lineLength(info.lineLength)
+          .indentUnit(info.indentUnit)
+          .cursorBlinkRate(info.cursorBlinkRate)
+          .hideTopMenu(info.hideTopMenu)
+          .showTabs(info.showTabs)
+          .showWhitespaceErrors(info.showWhitespaceErrors)
+          .syntaxHighlighting(info.syntaxHighlighting)
+          .hideLineNumbers(info.hideLineNumbers)
+          .matchBrackets(info.matchBrackets)
+          .lineWrapping(info.lineWrapping)
+          .indentWithTabs(info.indentWithTabs)
+          .autoCloseBrackets(info.autoCloseBrackets)
+          .showBase(info.showBase)
+          .build();
+    }
+
+    public EditPreferencesInfo toInfo() {
+      EditPreferencesInfo info = new EditPreferencesInfo();
+      info.tabSize = tabSize().orElse(null);
+      info.lineLength = lineLength().orElse(null);
+      info.indentUnit = indentUnit().orElse(null);
+      info.cursorBlinkRate = cursorBlinkRate().orElse(null);
+      info.hideTopMenu = hideTopMenu().orElse(null);
+      info.showTabs = showTabs().orElse(null);
+      info.showWhitespaceErrors = showWhitespaceErrors().orElse(null);
+      info.syntaxHighlighting = syntaxHighlighting().orElse(null);
+      info.hideLineNumbers = hideLineNumbers().orElse(null);
+      info.matchBrackets = matchBrackets().orElse(null);
+      info.lineWrapping = lineWrapping().orElse(null);
+      info.indentWithTabs = indentWithTabs().orElse(null);
+      info.autoCloseBrackets = autoCloseBrackets().orElse(null);
+      info.showBase = showBase().orElse(null);
+      return info;
     }
   }
 
-  private EditPreferencesInfo parseEditPreferences(@Nullable EditPreferencesInfo input) {
-    try {
-      return parseEditPreferences(cfg, defaultCfg, input);
-    } catch (ConfigInvalidException e) {
-      validationErrorSink.error(
-          new ValidationError(
-              PREFERENCES_CONFIG,
-              String.format(
-                  "Invalid edit preferences for account %d: %s", accountId.get(), e.getMessage())));
-      return new EditPreferencesInfo();
-    }
-  }
+  @AutoValue
+  public abstract static class Diff {
+    public abstract Optional<Integer> context();
 
-  private static GeneralPreferencesInfo parseGeneralPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
-      throws ConfigInvalidException {
-    GeneralPreferencesInfo r =
-        loadSection(
-            cfg,
-            UserConfigSections.GENERAL,
-            null,
-            new GeneralPreferencesInfo(),
-            defaultCfg != null
-                ? parseDefaultGeneralPreferences(defaultCfg, input)
-                : GeneralPreferencesInfo.defaults(),
-            input);
-    if (input != null) {
-      r.changeTable = input.changeTable;
-      r.my = input.my;
-    } else {
-      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
-    }
-    return r;
-  }
+    public abstract Optional<Integer> tabSize();
 
-  private static DiffPreferencesInfo parseDiffPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
-      throws ConfigInvalidException {
-    return loadSection(
-        cfg,
-        UserConfigSections.DIFF,
-        null,
-        new DiffPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultDiffPreferences(defaultCfg, input)
-            : DiffPreferencesInfo.defaults(),
-        input);
-  }
+    public abstract Optional<Integer> fontSize();
 
-  private static EditPreferencesInfo parseEditPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
-      throws ConfigInvalidException {
-    return loadSection(
-        cfg,
-        UserConfigSections.EDIT,
-        null,
-        new EditPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultEditPreferences(defaultCfg, input)
-            : EditPreferencesInfo.defaults(),
-        input);
-  }
+    public abstract Optional<Integer> lineLength();
 
-  private static GeneralPreferencesInfo parseDefaultGeneralPreferences(
-      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
-    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.GENERAL,
-        null,
-        allUserPrefs,
-        GeneralPreferencesInfo.defaults(),
-        input);
-    return updateGeneralPreferencesDefaults(allUserPrefs);
-  }
+    public abstract Optional<Integer> cursorBlinkRate();
 
-  private static DiffPreferencesInfo parseDefaultDiffPreferences(
-      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
-    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.DIFF,
-        null,
-        allUserPrefs,
-        DiffPreferencesInfo.defaults(),
-        input);
-    return updateDiffPreferencesDefaults(allUserPrefs);
-  }
+    public abstract Optional<Boolean> expandAllComments();
 
-  private static EditPreferencesInfo parseDefaultEditPreferences(
-      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
-    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.EDIT,
-        null,
-        allUserPrefs,
-        EditPreferencesInfo.defaults(),
-        input);
-    return updateEditPreferencesDefaults(allUserPrefs);
-  }
+    public abstract Optional<Boolean> intralineDifference();
 
-  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
-      GeneralPreferencesInfo input) {
-    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
-      return GeneralPreferencesInfo.defaults();
-    }
-    return result;
-  }
+    public abstract Optional<Boolean> manualReview();
 
-  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
-    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
-      return DiffPreferencesInfo.defaults();
-    }
-    return result;
-  }
+    public abstract Optional<Boolean> showLineEndings();
 
-  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
-    EditPreferencesInfo result = EditPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
-      return EditPreferencesInfo.defaults();
-    }
-    return result;
-  }
+    public abstract Optional<Boolean> showTabs();
 
-  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
-    List<String> changeTable = changeTable(cfg);
-    if (changeTable == null && defaultCfg != null) {
-      changeTable = changeTable(defaultCfg);
-    }
-    return changeTable;
-  }
+    public abstract Optional<Boolean> showWhitespaceErrors();
 
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
-      my = my(defaultCfg);
-    }
-    if (my.isEmpty()) {
-      my.add(new MenuItem("Changes", "#/dashboard/self", null));
-      my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
-      my.add(new MenuItem("Edits", "#/q/has:edit", null));
-      my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
-      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
-      my.add(new MenuItem("Groups", "#/groups/self", null));
-    }
-    return my;
-  }
+    public abstract Optional<Boolean> syntaxHighlighting();
 
-  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
+    public abstract Optional<Boolean> hideTopMenu();
 
-  public static DiffPreferencesInfo readDefaultDiffPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
+    public abstract Optional<Boolean> autoHideDiffTableHeader();
 
-  public static EditPreferencesInfo readDefaultEditPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
+    public abstract Optional<Boolean> hideLineNumbers();
 
-  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(allUsersName, allUsersRepo);
-    return defaultPrefs.getConfig();
-  }
+    public abstract Optional<Boolean> renderEntireFile();
 
-  public static GeneralPreferencesInfo updateDefaultGeneralPreferences(
-      MetaDataUpdate md, GeneralPreferencesInfo input) throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(md);
-    storeSection(
-        defaultPrefs.getConfig(),
-        UserConfigSections.GENERAL,
-        null,
-        input,
-        GeneralPreferencesInfo.defaults());
-    setMy(defaultPrefs.getConfig(), input.my);
-    setChangeTable(defaultPrefs.getConfig(), input.changeTable);
-    defaultPrefs.commit(md);
+    public abstract Optional<Boolean> hideEmptyPane();
 
-    return parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
-  }
+    public abstract Optional<Boolean> matchBrackets();
 
-  public static DiffPreferencesInfo updateDefaultDiffPreferences(
-      MetaDataUpdate md, DiffPreferencesInfo input) throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(md);
-    storeSection(
-        defaultPrefs.getConfig(),
-        UserConfigSections.DIFF,
-        null,
-        input,
-        DiffPreferencesInfo.defaults());
-    defaultPrefs.commit(md);
+    public abstract Optional<Boolean> lineWrapping();
 
-    return parseDiffPreferences(defaultPrefs.getConfig(), null, null);
-  }
+    public abstract Optional<Whitespace> ignoreWhitespace();
 
-  public static EditPreferencesInfo updateDefaultEditPreferences(
-      MetaDataUpdate md, EditPreferencesInfo input) throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(md);
-    storeSection(
-        defaultPrefs.getConfig(),
-        UserConfigSections.EDIT,
-        null,
-        input,
-        EditPreferencesInfo.defaults());
-    defaultPrefs.commit(md);
+    public abstract Optional<Boolean> retainHeader();
 
-    return parseEditPreferences(defaultPrefs.getConfig(), null, null);
-  }
+    public abstract Optional<Boolean> skipDeleted();
 
-  private static List<String> changeTable(Config cfg) {
-    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
-  }
+    public abstract Optional<Boolean> skipUnchanged();
 
-  private static void setChangeTable(Config cfg, List<String> changeTable) {
-    if (changeTable != null) {
-      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
-      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
-    }
-  }
+    public abstract Optional<Boolean> skipUncommented();
 
-  private static List<MenuItem> my(Config cfg) {
-    List<MenuItem> my = new ArrayList<>();
-    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
-      String url = my(cfg, subsection, KEY_URL, "#/");
-      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
-      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
-    }
-    return my;
-  }
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder context(@Nullable Integer val);
 
-  private static String my(Config cfg, String subsection, String key, String defaultValue) {
-    String val = cfg.getString(UserConfigSections.MY, subsection, key);
-    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
-  }
+      abstract Builder tabSize(@Nullable Integer val);
 
-  private static void setMy(Config cfg, List<MenuItem> my) {
-    if (my != null) {
-      unsetSection(cfg, UserConfigSections.MY);
-      for (MenuItem item : my) {
-        checkState(!isNullOrEmpty(item.name), "MenuItem.name must not be null or empty");
-        checkState(!isNullOrEmpty(item.url), "MenuItem.url must not be null or empty");
+      abstract Builder fontSize(@Nullable Integer val);
 
-        setMy(cfg, item.name, KEY_URL, item.url);
-        setMy(cfg, item.name, KEY_TARGET, item.target);
-        setMy(cfg, item.name, KEY_ID, item.id);
-      }
-    }
-  }
+      abstract Builder lineLength(@Nullable Integer val);
 
-  public static void validateMy(List<MenuItem> my) throws BadRequestException {
-    if (my == null) {
-      return;
-    }
-    for (MenuItem item : my) {
-      checkRequiredMenuItemField(item.name, "name");
-      checkRequiredMenuItemField(item.url, "URL");
-    }
-  }
+      abstract Builder cursorBlinkRate(@Nullable Integer val);
 
-  private static void checkRequiredMenuItemField(String value, String name)
-      throws BadRequestException {
-    if (isNullOrEmpty(value)) {
-      throw new BadRequestException(name + " for menu item is required");
-    }
-  }
+      abstract Builder expandAllComments(@Nullable Boolean val);
 
-  private static boolean isNullOrEmpty(String value) {
-    return value == null || value.trim().isEmpty();
-  }
+      abstract Builder intralineDifference(@Nullable Boolean val);
 
-  private static void setMy(Config cfg, String section, String key, @Nullable String val) {
-    if (val == null || val.trim().isEmpty()) {
-      cfg.unset(UserConfigSections.MY, section.trim(), key);
-    } else {
-      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
-    }
-  }
+      abstract Builder manualReview(@Nullable Boolean val);
 
-  private static void unsetSection(Config cfg, String section) {
-    cfg.unsetSection(section, null);
-    for (String subsection : cfg.getSubsections(section)) {
-      cfg.unsetSection(section, subsection);
-    }
-  }
+      abstract Builder showLineEndings(@Nullable Boolean val);
 
-  private static class VersionedDefaultPreferences extends VersionedMetaData {
-    private Config cfg;
+      abstract Builder showTabs(@Nullable Boolean val);
 
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_USERS_DEFAULT;
+      abstract Builder showWhitespaceErrors(@Nullable Boolean val);
+
+      abstract Builder syntaxHighlighting(@Nullable Boolean val);
+
+      abstract Builder hideTopMenu(@Nullable Boolean val);
+
+      abstract Builder autoHideDiffTableHeader(@Nullable Boolean val);
+
+      abstract Builder hideLineNumbers(@Nullable Boolean val);
+
+      abstract Builder renderEntireFile(@Nullable Boolean val);
+
+      abstract Builder hideEmptyPane(@Nullable Boolean val);
+
+      abstract Builder matchBrackets(@Nullable Boolean val);
+
+      abstract Builder lineWrapping(@Nullable Boolean val);
+
+      abstract Builder ignoreWhitespace(@Nullable Whitespace val);
+
+      abstract Builder retainHeader(@Nullable Boolean val);
+
+      abstract Builder skipDeleted(@Nullable Boolean val);
+
+      abstract Builder skipUnchanged(@Nullable Boolean val);
+
+      abstract Builder skipUncommented(@Nullable Boolean val);
+
+      abstract Diff build();
     }
 
-    private Config getConfig() {
-      checkState(cfg != null, "Default preferences not loaded yet.");
-      return cfg;
+    public static Diff fromInfo(DiffPreferencesInfo info) {
+      return (new AutoValue_Preferences_Diff.Builder())
+          .context(info.context)
+          .tabSize(info.tabSize)
+          .fontSize(info.fontSize)
+          .lineLength(info.lineLength)
+          .cursorBlinkRate(info.cursorBlinkRate)
+          .expandAllComments(info.expandAllComments)
+          .intralineDifference(info.intralineDifference)
+          .manualReview(info.manualReview)
+          .showLineEndings(info.showLineEndings)
+          .showTabs(info.showTabs)
+          .showWhitespaceErrors(info.showWhitespaceErrors)
+          .syntaxHighlighting(info.syntaxHighlighting)
+          .hideTopMenu(info.hideTopMenu)
+          .autoHideDiffTableHeader(info.autoHideDiffTableHeader)
+          .hideLineNumbers(info.hideLineNumbers)
+          .renderEntireFile(info.renderEntireFile)
+          .hideEmptyPane(info.hideEmptyPane)
+          .matchBrackets(info.matchBrackets)
+          .lineWrapping(info.lineWrapping)
+          .ignoreWhitespace(info.ignoreWhitespace)
+          .retainHeader(info.retainHeader)
+          .skipDeleted(info.skipDeleted)
+          .skipUnchanged(info.skipUnchanged)
+          .skipUncommented(info.skipUncommented)
+          .build();
     }
 
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      cfg = readConfig(PREFERENCES_CONFIG);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      if (Strings.isNullOrEmpty(commit.getMessage())) {
-        commit.setMessage("Update default preferences\n");
-      }
-      saveConfig(PREFERENCES_CONFIG, cfg);
-      return true;
+    public DiffPreferencesInfo toInfo() {
+      DiffPreferencesInfo info = new DiffPreferencesInfo();
+      info.context = context().orElse(null);
+      info.tabSize = tabSize().orElse(null);
+      info.fontSize = fontSize().orElse(null);
+      info.lineLength = lineLength().orElse(null);
+      info.cursorBlinkRate = cursorBlinkRate().orElse(null);
+      info.expandAllComments = expandAllComments().orElse(null);
+      info.intralineDifference = intralineDifference().orElse(null);
+      info.manualReview = manualReview().orElse(null);
+      info.showLineEndings = showLineEndings().orElse(null);
+      info.showTabs = showTabs().orElse(null);
+      info.showWhitespaceErrors = showWhitespaceErrors().orElse(null);
+      info.syntaxHighlighting = syntaxHighlighting().orElse(null);
+      info.hideTopMenu = hideTopMenu().orElse(null);
+      info.autoHideDiffTableHeader = autoHideDiffTableHeader().orElse(null);
+      info.hideLineNumbers = hideLineNumbers().orElse(null);
+      info.renderEntireFile = renderEntireFile().orElse(null);
+      info.hideEmptyPane = hideEmptyPane().orElse(null);
+      info.matchBrackets = matchBrackets().orElse(null);
+      info.lineWrapping = lineWrapping().orElse(null);
+      info.ignoreWhitespace = ignoreWhitespace().orElse(null);
+      info.retainHeader = retainHeader().orElse(null);
+      info.skipDeleted = skipDeleted().orElse(null);
+      info.skipUnchanged = skipUnchanged().orElse(null);
+      info.skipUncommented = skipUncommented().orElse(null);
+      return info;
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index a750ba5..b153b78 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -30,8 +30,8 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -168,7 +168,7 @@
         }
 
         ProjectWatchKey key =
-            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
+            ProjectWatchKey.create(Project.nameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
           projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
         }
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 4e8cf09..798b4e8 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index da2d640..fb3d4ea 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
@@ -56,7 +56,7 @@
             "Deactivate Account via API",
             accountId,
             (a, u) -> {
-              if (!a.getAccount().isActive()) {
+              if (!a.account().isActive()) {
                 alreadyInactive.set(true);
               } else {
                 try {
@@ -89,7 +89,7 @@
             "Activate Account via API",
             accountId,
             (a, u) -> {
-              if (a.getAccount().isActive()) {
+              if (a.account().isActive()) {
                 alreadyActive.set(true);
               } else {
                 try {
diff --git a/java/com/google/gerrit/server/account/StoredPreferences.java b/java/com/google/gerrit/server/account/StoredPreferences.java
new file mode 100644
index 0000000..0e8eb04
--- /dev/null
+++ b/java/com/google/gerrit/server/account/StoredPreferences.java
@@ -0,0 +1,574 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Parses/writes preferences from/to a {@link Config} file.
+ *
+ * <p>This is a low-level API. Read/write of preferences in a user branch should be done through
+ * {@link AccountsUpdate} or {@link AccountConfig}.
+ *
+ * <p>The config file has separate sections for general, diff and edit preferences:
+ *
+ * <pre>
+ *   [diff]
+ *     hideTopMenu = true
+ *   [edit]
+ *     lineLength = 80
+ * </pre>
+ *
+ * <p>The parameter names match the names that are used in the preferences REST API.
+ *
+ * <p>If the preference is omitted in the config file, then the default value for the preference is
+ * used.
+ *
+ * <p>Defaults for preferences that apply for all accounts can be configured in the {@code
+ * refs/users/default} branch in the {@code All-Users} repository. The config for the default
+ * preferences must be provided to this class so that it can read default values from it.
+ *
+ * <p>The preferences are lazily parsed.
+ */
+public class StoredPreferences {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String PREFERENCES_CONFIG = "preferences.config";
+
+  private final Account.Id accountId;
+  private final Config cfg;
+  private final Config defaultCfg;
+  private final ValidationError.Sink validationErrorSink;
+
+  private GeneralPreferencesInfo generalPreferences;
+  private DiffPreferencesInfo diffPreferences;
+  private EditPreferencesInfo editPreferences;
+
+  StoredPreferences(
+      Account.Id accountId,
+      Config cfg,
+      Config defaultCfg,
+      ValidationError.Sink validationErrorSink) {
+    this.accountId = requireNonNull(accountId, "accountId");
+    this.cfg = requireNonNull(cfg, "cfg");
+    this.defaultCfg = requireNonNull(defaultCfg, "defaultCfg");
+    this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
+  }
+
+  public GeneralPreferencesInfo getGeneralPreferences() {
+    if (generalPreferences == null) {
+      parse();
+    }
+    return generalPreferences;
+  }
+
+  public DiffPreferencesInfo getDiffPreferences() {
+    if (diffPreferences == null) {
+      parse();
+    }
+    return diffPreferences;
+  }
+
+  public EditPreferencesInfo getEditPreferences() {
+    if (editPreferences == null) {
+      parse();
+    }
+    return editPreferences;
+  }
+
+  public void parse() {
+    generalPreferences = parseGeneralPreferences(null);
+    diffPreferences = parseDiffPreferences(null);
+    editPreferences = parseEditPreferences(null);
+  }
+
+  public Config saveGeneralPreferences(
+      Optional<GeneralPreferencesInfo> generalPreferencesInput,
+      Optional<DiffPreferencesInfo> diffPreferencesInput,
+      Optional<EditPreferencesInfo> editPreferencesInput)
+      throws ConfigInvalidException {
+    if (generalPreferencesInput.isPresent()) {
+      GeneralPreferencesInfo mergedGeneralPreferencesInput =
+          parseGeneralPreferences(generalPreferencesInput.get());
+
+      storeSection(
+          cfg,
+          UserConfigSections.GENERAL,
+          null,
+          mergedGeneralPreferencesInput,
+          parseDefaultGeneralPreferences(defaultCfg, null));
+      setChangeTable(cfg, mergedGeneralPreferencesInput.changeTable);
+      setMy(cfg, mergedGeneralPreferencesInput.my);
+
+      // evict the cached general preferences
+      this.generalPreferences = null;
+    }
+
+    if (diffPreferencesInput.isPresent()) {
+      DiffPreferencesInfo mergedDiffPreferencesInput =
+          parseDiffPreferences(diffPreferencesInput.get());
+
+      storeSection(
+          cfg,
+          UserConfigSections.DIFF,
+          null,
+          mergedDiffPreferencesInput,
+          parseDefaultDiffPreferences(defaultCfg, null));
+
+      // evict the cached diff preferences
+      this.diffPreferences = null;
+    }
+
+    if (editPreferencesInput.isPresent()) {
+      EditPreferencesInfo mergedEditPreferencesInput =
+          parseEditPreferences(editPreferencesInput.get());
+
+      storeSection(
+          cfg,
+          UserConfigSections.EDIT,
+          null,
+          mergedEditPreferencesInput,
+          parseDefaultEditPreferences(defaultCfg, null));
+
+      // evict the cached edit preferences
+      this.editPreferences = null;
+    }
+
+    return cfg;
+  }
+
+  private GeneralPreferencesInfo parseGeneralPreferences(@Nullable GeneralPreferencesInfo input) {
+    try {
+      return parseGeneralPreferences(cfg, defaultCfg, input);
+    } catch (ConfigInvalidException e) {
+      validationErrorSink.error(
+          new ValidationError(
+              PREFERENCES_CONFIG,
+              String.format(
+                  "Invalid general preferences for account %d: %s",
+                  accountId.get(), e.getMessage())));
+      return new GeneralPreferencesInfo();
+    }
+  }
+
+  private DiffPreferencesInfo parseDiffPreferences(@Nullable DiffPreferencesInfo input) {
+    try {
+      return parseDiffPreferences(cfg, defaultCfg, input);
+    } catch (ConfigInvalidException e) {
+      validationErrorSink.error(
+          new ValidationError(
+              PREFERENCES_CONFIG,
+              String.format(
+                  "Invalid diff preferences for account %d: %s", accountId.get(), e.getMessage())));
+      return new DiffPreferencesInfo();
+    }
+  }
+
+  private EditPreferencesInfo parseEditPreferences(@Nullable EditPreferencesInfo input) {
+    try {
+      return parseEditPreferences(cfg, defaultCfg, input);
+    } catch (ConfigInvalidException e) {
+      validationErrorSink.error(
+          new ValidationError(
+              PREFERENCES_CONFIG,
+              String.format(
+                  "Invalid edit preferences for account %d: %s", accountId.get(), e.getMessage())));
+      return new EditPreferencesInfo();
+    }
+  }
+
+  private static GeneralPreferencesInfo parseGeneralPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+      throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        loadSection(
+            cfg,
+            UserConfigSections.GENERAL,
+            null,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, input)
+                : GeneralPreferencesInfo.defaults(),
+            input);
+    if (input != null) {
+      r.changeTable = input.changeTable;
+      r.my = input.my;
+    } else {
+      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
+      r.my = parseMyMenus(cfg, defaultCfg);
+    }
+    return r;
+  }
+
+  private static DiffPreferencesInfo parseDiffPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
+      throws ConfigInvalidException {
+    return loadSection(
+        cfg,
+        UserConfigSections.DIFF,
+        null,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, input)
+            : DiffPreferencesInfo.defaults(),
+        input);
+  }
+
+  private static EditPreferencesInfo parseEditPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
+      throws ConfigInvalidException {
+    return loadSection(
+        cfg,
+        UserConfigSections.EDIT,
+        null,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, input)
+            : EditPreferencesInfo.defaults(),
+        input);
+  }
+
+  private static GeneralPreferencesInfo parseDefaultGeneralPreferences(
+      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
+    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.GENERAL,
+        null,
+        allUserPrefs,
+        GeneralPreferencesInfo.defaults(),
+        input);
+    return updateGeneralPreferencesDefaults(allUserPrefs);
+  }
+
+  private static DiffPreferencesInfo parseDefaultDiffPreferences(
+      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
+    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.DIFF,
+        null,
+        allUserPrefs,
+        DiffPreferencesInfo.defaults(),
+        input);
+    return updateDiffPreferencesDefaults(allUserPrefs);
+  }
+
+  private static EditPreferencesInfo parseDefaultEditPreferences(
+      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
+    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.EDIT,
+        null,
+        allUserPrefs,
+        EditPreferencesInfo.defaults(),
+        input);
+    return updateEditPreferencesDefaults(allUserPrefs);
+  }
+
+  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
+      GeneralPreferencesInfo input) {
+    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
+      return GeneralPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
+    EditPreferencesInfo result = EditPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
+      return EditPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
+    List<String> changeTable = changeTable(cfg);
+    if (changeTable == null && defaultCfg != null) {
+      changeTable = changeTable(defaultCfg);
+    }
+    return changeTable;
+  }
+
+  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
+    List<MenuItem> my = my(cfg);
+    if (my.isEmpty() && defaultCfg != null) {
+      my = my(defaultCfg);
+    }
+    if (my.isEmpty()) {
+      my.add(new MenuItem("Changes", "#/dashboard/self", null));
+      my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
+      my.add(new MenuItem("Edits", "#/q/has:edit", null));
+      my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
+      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("Groups", "#/settings/#Groups", null));
+    }
+    return my;
+  }
+
+  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
+  }
+
+  public static DiffPreferencesInfo readDefaultDiffPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
+  }
+
+  public static EditPreferencesInfo readDefaultEditPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
+  }
+
+  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(allUsersName, allUsersRepo);
+    return defaultPrefs.getConfig();
+  }
+
+  public static GeneralPreferencesInfo updateDefaultGeneralPreferences(
+      MetaDataUpdate md, GeneralPreferencesInfo input) throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(md);
+    storeSection(
+        defaultPrefs.getConfig(),
+        UserConfigSections.GENERAL,
+        null,
+        input,
+        GeneralPreferencesInfo.defaults());
+    setMy(defaultPrefs.getConfig(), input.my);
+    setChangeTable(defaultPrefs.getConfig(), input.changeTable);
+    defaultPrefs.commit(md);
+
+    return parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
+  }
+
+  public static DiffPreferencesInfo updateDefaultDiffPreferences(
+      MetaDataUpdate md, DiffPreferencesInfo input) throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(md);
+    storeSection(
+        defaultPrefs.getConfig(),
+        UserConfigSections.DIFF,
+        null,
+        input,
+        DiffPreferencesInfo.defaults());
+    defaultPrefs.commit(md);
+
+    return parseDiffPreferences(defaultPrefs.getConfig(), null, null);
+  }
+
+  public static EditPreferencesInfo updateDefaultEditPreferences(
+      MetaDataUpdate md, EditPreferencesInfo input) throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(md);
+    storeSection(
+        defaultPrefs.getConfig(),
+        UserConfigSections.EDIT,
+        null,
+        input,
+        EditPreferencesInfo.defaults());
+    defaultPrefs.commit(md);
+
+    return parseEditPreferences(defaultPrefs.getConfig(), null, null);
+  }
+
+  private static List<String> changeTable(Config cfg) {
+    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
+  private static void setChangeTable(Config cfg, List<String> changeTable) {
+    if (changeTable != null) {
+      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
+      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
+    }
+  }
+
+  private static List<MenuItem> my(Config cfg) {
+    List<MenuItem> my = new ArrayList<>();
+    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
+      String url = my(cfg, subsection, KEY_URL, "#/");
+      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
+    }
+    return my;
+  }
+
+  private static String my(Config cfg, String subsection, String key, String defaultValue) {
+    String val = cfg.getString(UserConfigSections.MY, subsection, key);
+    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
+  }
+
+  private static void setMy(Config cfg, List<MenuItem> my) {
+    if (my != null) {
+      unsetSection(cfg, UserConfigSections.MY);
+      for (MenuItem item : my) {
+        checkState(!isNullOrEmpty(item.name), "MenuItem.name must not be null or empty");
+        checkState(!isNullOrEmpty(item.url), "MenuItem.url must not be null or empty");
+
+        setMy(cfg, item.name, KEY_URL, item.url);
+        setMy(cfg, item.name, KEY_TARGET, item.target);
+        setMy(cfg, item.name, KEY_ID, item.id);
+      }
+    }
+  }
+
+  public static void validateMy(List<MenuItem> my) throws BadRequestException {
+    if (my == null) {
+      return;
+    }
+    for (MenuItem item : my) {
+      checkRequiredMenuItemField(item.name, "name");
+      checkRequiredMenuItemField(item.url, "URL");
+    }
+  }
+
+  private static void checkRequiredMenuItemField(String value, String name)
+      throws BadRequestException {
+    if (isNullOrEmpty(value)) {
+      throw new BadRequestException(name + " for menu item is required");
+    }
+  }
+
+  private static boolean isNullOrEmpty(String value) {
+    return value == null || value.trim().isEmpty();
+  }
+
+  private static void setMy(Config cfg, String section, String key, @Nullable String val) {
+    if (val == null || val.trim().isEmpty()) {
+      cfg.unset(UserConfigSections.MY, section.trim(), key);
+    } else {
+      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
+    }
+  }
+
+  private static void unsetSection(Config cfg, String section) {
+    cfg.unsetSection(section, null);
+    for (String subsection : cfg.getSubsections(section)) {
+      cfg.unsetSection(section, subsection);
+    }
+  }
+
+  private static class VersionedDefaultPreferences extends VersionedMetaData {
+    private Config cfg;
+
+    @Override
+    protected String getRefName() {
+      return RefNames.REFS_USERS_DEFAULT;
+    }
+
+    private Config getConfig() {
+      checkState(cfg != null, "Default preferences not loaded yet.");
+      return cfg;
+    }
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      cfg = readConfig(PREFERENCES_CONFIG);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Update default preferences\n");
+      }
+      saveConfig(PREFERENCES_CONFIG, cfg);
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index ecd7468..fddbd2b 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -220,7 +220,7 @@
           cfg.getSubsections("groups").stream()
               .filter(
                   sub -> {
-                    AccountGroup.UUID uuid = new AccountGroup.UUID(sub);
+                    AccountGroup.UUID uuid = AccountGroup.uuid(sub);
                     GroupBackend groupBackend = universalGroupBackend.backend(uuid);
                     return groupBackend == null || groupBackend.get(uuid) == null;
                   })
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index e2f1bc2..f1cf9fe 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index daf7100..e2ffe9b 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 
@@ -46,6 +52,16 @@
     return queryList;
   }
 
+  public void setQueryList(String text) throws IOException, ConfigInvalidException {
+    List<ValidationError> errors = new ArrayList<>();
+    QueryList newQueryList = QueryList.parse(text, error -> errors.add(error));
+    if (!errors.isEmpty()) {
+      String messages = errors.stream().map(ValidationError::getMessage).collect(joining(", "));
+      throw new ConfigInvalidException("Invalid named queries: " + messages);
+    }
+    queryList = newQueryList;
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     queryList =
@@ -58,6 +74,10 @@
 
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    throw new UnsupportedOperationException("Cannot yet save named queries");
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Updated named queries\n");
+    }
+    saveUTF8(QueryList.FILE_NAME, queryList.asText());
+    return true;
   }
 }
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index c7808de..235537c 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,9 +20,9 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index 5d12ae1..4da2a9e 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -21,8 +21,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.SetMultimap;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -96,7 +96,7 @@
     private static ExternalId toExternalId(ObjectIdConverter idConverter, ExternalIdProto proto) {
       return ExternalId.create(
           ExternalId.Key.parse(proto.getKey()),
-          new Account.Id(proto.getAccountId()),
+          Account.id(proto.getAccountId()),
           // ExternalId treats null and empty strings the same, so no need to distinguish here.
           proto.getEmail(),
           proto.getPassword(),
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
index 5894051..e1e9c70 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index ab82714..2d501ad 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -27,8 +27,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
 import java.util.Collection;
@@ -39,7 +40,6 @@
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -364,7 +364,7 @@
 
     return create(
         externalIdKey,
-        new Account.Id(accountId),
+        Account.id(accountId),
         Strings.emptyToNull(email),
         Strings.emptyToNull(password),
         blobId);
@@ -432,10 +432,10 @@
 
   public byte[] toByteArray() {
     checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
     key().sha1().copyTo(b, 0);
-    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
-    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    b[ObjectIds.STR_LEN] = ':';
+    blobId().copyTo(b, ObjectIds.STR_LEN + 1);
     return b;
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 1ac737e..0edf154 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.account.externalids;
 
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 5aa19d8..84b25c0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
@@ -146,23 +142,4 @@
       lock.unlock();
     }
   }
-
-  static class Loader extends CacheLoader<ObjectId, AllExternalIds> {
-    private final ExternalIdReader externalIdReader;
-
-    @Inject
-    Loader(ExternalIdReader externalIdReader) {
-      this.externalIdReader = externalIdReader;
-    }
-
-    @Override
-    public AllExternalIds load(ObjectId notesRev) throws Exception {
-      try (TraceTimer timer =
-          TraceContext.newTimer("Loading external IDs (revision=%s)", notesRev)) {
-        ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
-        externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
-        return AllExternalIds.create(externalIds);
-      }
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
new file mode 100644
index 0000000..8887e06
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -0,0 +1,277 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+/** Loads cache values for the external ID cache using either a full or a partial reload. */
+@Singleton
+public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Maximum number of prior states we inspect to find a base for differential. If no cached state
+  // is found within this number of parents, we fall back to reading everything from scratch.
+  private static final int MAX_HISTORY_LOOKBACK = 10;
+
+  private final ExternalIdReader externalIdReader;
+  private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache;
+  private final GitRepositoryManager gitRepositoryManager;
+  private final AllUsersName allUsersName;
+  private final Counter1<Boolean> reloadCounter;
+  private final Timer0 reloadDifferential;
+  private final boolean enablePartialReloads;
+  private final boolean isPersistentCache;
+
+  @Inject
+  ExternalIdCacheLoader(
+      GitRepositoryManager gitRepositoryManager,
+      AllUsersName allUsersName,
+      ExternalIdReader externalIdReader,
+      @Named(ExternalIdCacheImpl.CACHE_NAME)
+          Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.allUsersName = allUsersName;
+    this.reloadCounter =
+        metricMaker.newCounter(
+            "notedb/external_id_cache_load_count",
+            new Description("Total number of external ID cache reloads from Git.")
+                .setRate()
+                .setUnit("updates"),
+            Field.ofBoolean("partial", Metadata.Builder::partial).build());
+    this.reloadDifferential =
+        metricMaker.newTimer(
+            "notedb/external_id_partial_read_latency",
+            new Description(
+                    "Latency for generating a new external ID cache state from a prior state.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+    this.enablePartialReloads =
+        config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", true);
+    this.isPersistentCache =
+        config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0;
+  }
+
+  @Override
+  public AllExternalIds load(ObjectId notesRev) throws IOException, ConfigInvalidException {
+    if (!enablePartialReloads) {
+      logger.atInfo().log(
+          "Partial reloads of "
+              + ExternalIdCacheImpl.CACHE_NAME
+              + " disabled. Falling back to full reload.");
+      return reloadAllExternalIds(notesRev);
+    }
+
+    // The requested value was not in the cache (hence, this loader was invoked). Therefore, try to
+    // create this entry from a past value using the minimal amount of Git operations possible to
+    // reduce latency.
+    //
+    // First, try to find the most recent state we have in the cache. Most of the time, this will be
+    // the state before the last update happened, but it can also date further back. We try a best
+    // effort approach and check the last 10 states. If nothing is found, we default to loading the
+    // value from scratch.
+    //
+    // If a prior state was found, we use Git to diff the trees and find modifications. This is
+    // faster than just loading the complete current tree and working off of that because of how the
+    // data is structured: NotesMaps use nested trees, so, for example, a NotesMap with 200k entries
+    // has two layers of nesting: 12/34/1234..99. TreeWalk is smart in skipping the traversal of
+    // identical subtrees.
+    //
+    // Once we know what files changed, we apply additions and removals to the previously cached
+    // state.
+
+    try (Repository repo = gitRepositoryManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      long start = System.nanoTime();
+      Ref extIdRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+      if (extIdRef == null) {
+        logger.atInfo().log(
+            RefNames.REFS_EXTERNAL_IDS + " not initialized, falling back to full reload.");
+        return reloadAllExternalIds(notesRev);
+      }
+
+      RevCommit currentCommit = rw.parseCommit(extIdRef.getObjectId());
+      rw.markStart(currentCommit);
+      RevCommit parentWithCacheValue;
+      AllExternalIds oldExternalIds = null;
+      int i = 0;
+      while ((parentWithCacheValue = rw.next()) != null
+          && i++ < MAX_HISTORY_LOOKBACK
+          && parentWithCacheValue.getParentCount() < 2) {
+        oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId());
+        if (oldExternalIds != null) {
+          // We found a previously cached state.
+          break;
+        }
+      }
+      if (oldExternalIds == null) {
+        if (isPersistentCache) {
+          // If there is no persistence, this is normal. Don't upset admins reading the logs.
+          logger.atWarning().log(
+              "Unable to find an old ExternalId cache state, falling back to full reload");
+        }
+        return reloadAllExternalIds(notesRev);
+      }
+
+      // Diff trees to recognize modifications
+      Set<ObjectId> removals = new HashSet<>(); // Set<Blob-Object-Id>
+      Map<ObjectId, ObjectId> additions = new HashMap<>(); // Map<Name-ObjectId, Blob-Object-Id>
+      try (TreeWalk treeWalk = new TreeWalk(repo)) {
+        treeWalk.setFilter(TreeFilter.ANY_DIFF);
+        treeWalk.setRecursive(true);
+        treeWalk.reset(parentWithCacheValue.getTree(), currentCommit.getTree());
+        while (treeWalk.next()) {
+          String path = treeWalk.getPathString();
+          ObjectId oldBlob = treeWalk.getObjectId(0);
+          ObjectId newBlob = treeWalk.getObjectId(1);
+          if (ObjectId.zeroId().equals(newBlob)) {
+            // Deletion
+            removals.add(oldBlob);
+          } else if (ObjectId.zeroId().equals(oldBlob)) {
+            // Addition
+            additions.put(fileNameToObjectId(path), newBlob);
+          } else {
+            // Modification
+            removals.add(oldBlob);
+            additions.put(fileNameToObjectId(path), newBlob);
+          }
+        }
+      }
+
+      AllExternalIds allExternalIds =
+          buildAllExternalIds(repo, oldExternalIds, additions, removals);
+      reloadCounter.increment(true);
+      reloadDifferential.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
+      return allExternalIds;
+    }
+  }
+
+  private static ObjectId fileNameToObjectId(String path) {
+    return ObjectId.fromString(CharMatcher.is('/').removeFrom(path));
+  }
+
+  /**
+   * Build a new {@link AllExternalIds} from an old state by applying additions and removals that
+   * were performed since then.
+   *
+   * <p>Removals are applied before additions.
+   *
+   * @param repo open repository
+   * @param oldExternalIds prior state that is used as base
+   * @param additions map of name to blob ID for each external ID that should be added
+   * @param removals set of name {@link ObjectId}s that should be removed
+   */
+  private static AllExternalIds buildAllExternalIds(
+      Repository repo,
+      AllExternalIds oldExternalIds,
+      Map<ObjectId, ObjectId> additions,
+      Set<ObjectId> removals)
+      throws IOException {
+    ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder();
+    ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder();
+
+    // Copy over old ExternalIds but exclude deleted ones
+    for (ExternalId externalId : oldExternalIds.byAccount().values()) {
+      if (removals.contains(externalId.blobId())) {
+        continue;
+      }
+
+      byAccount.put(externalId.accountId(), externalId);
+      if (externalId.email() != null) {
+        byEmail.put(externalId.email(), externalId);
+      }
+    }
+
+    // Add newly discovered ExternalIds
+    try (ObjectReader reader = repo.newObjectReader()) {
+      for (Map.Entry<ObjectId, ObjectId> nameToBlob : additions.entrySet()) {
+        ExternalId parsedExternalId;
+        try {
+          parsedExternalId =
+              ExternalId.parse(
+                  nameToBlob.getKey().name(),
+                  reader.open(nameToBlob.getValue()).getCachedBytes(),
+                  nameToBlob.getValue());
+        } catch (ConfigInvalidException | RuntimeException e) {
+          logger.atSevere().withCause(e).log(
+              "Ignoring invalid external ID note %s", nameToBlob.getKey().name());
+          continue;
+        }
+
+        byAccount.put(parsedExternalId.accountId(), parsedExternalId);
+        if (parsedExternalId.email() != null) {
+          byEmail.put(parsedExternalId.email(), parsedExternalId);
+        }
+      }
+    }
+    return new AutoValue_AllExternalIds(byAccount.build(), byEmail.build());
+  }
+
+  private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
+      throws IOException, ConfigInvalidException {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading external IDs from scratch",
+            Metadata.builder().revision(notesRev.name()).build())) {
+      ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
+      externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
+      AllExternalIds allExternalIds = AllExternalIds.create(externalIds);
+      reloadCounter.increment(false);
+      return allExternalIds;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index fc311e7..3e5d7b8 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.gerrit.server.account.externalids.ExternalIdCacheImpl.Loader;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
 import com.google.inject.TypeLiteral;
@@ -31,9 +30,12 @@
         // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
         // object after a short period of time, since it may be a potentially large amount of
         // memory.
+        // When loading a new value because the primary data advanced, we want to leverage the old
+        // cache state to recompute only what changed. This doesn't affect cache size though as
+        // Guava calls the loader first and evicts later on.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(Loader.class)
+        .loader(ExternalIdCacheLoader.class)
         .diskLimit(-1)
         .version(1)
         .keySerializer(ObjectIdCacheSerializer.INSTANCE)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 9c35232a..ec4f5534 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -28,17 +28,21 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -264,6 +268,7 @@
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
+  private final CallerFinder callerFinder;
 
   private NoteMap noteMap;
   private ObjectId oldRev;
@@ -293,6 +298,19 @@
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+    this.callerFinder =
+        CallerFinder.builder()
+            // 1. callers that come through ExternalIds
+            .addTarget(ExternalIds.class)
+
+            // 2. callers that come through AccountsUpdate
+            .addTarget(AccountsUpdate.class)
+            .addIgnoredPackage("com.github.rholder.retry")
+            .addIgnoredClass(RetryHelper.class)
+
+            // 3. direct callers
+            .addTarget(ExternalIdNotes.class)
+            .build();
   }
 
   public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
@@ -657,7 +675,8 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
-      logger.atFine().log("Reading external ID note map");
+      logger.atFine().log(
+          "Reading external ID note map (caller: %s)", callerFinder.findCallerLazy());
       noteMap = NoteMap.read(reader, revision);
     } else {
       noteMap = NoteMap.newEmptyMap();
@@ -670,7 +689,7 @@
 
   @Override
   public RevCommit commit(MetaDataUpdate update) throws IOException {
-    oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+    oldRev = ObjectIds.copyOrZero(revision);
     RevCommit commit = super.commit(update);
     updateCount.increment();
     return commit;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index cf5500e..6334265 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -17,11 +17,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 9098630..28d3af2 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 4897487..815f7d0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -30,7 +30,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import org.apache.commons.codec.DecoderException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -134,7 +133,7 @@
     if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
       try {
         HashedPassword.decode(extId.password());
-      } catch (DecoderException e) {
+      } catch (HashedPassword.DecoderException e) {
         addError(
             String.format(
                 "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
new file mode 100644
index 0000000..3f2f774
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.account.HashedPassword;
+import java.util.Collection;
+
+/** Checks if a given username and password match a user's external IDs. */
+public class PasswordVerifier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Returns {@code true} if there is an external ID matching both the username and password. */
+  public static boolean checkPassword(
+      Collection<ExternalId> externalIds, String username, @Nullable String password) {
+    if (password == null) {
+      return false;
+    }
+    for (ExternalId id : externalIds) {
+      // Only process the "username:$USER" entry, which is unique.
+      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+        continue;
+      }
+
+      String hashedStr = id.password();
+      if (!Strings.isNullOrEmpty(hashedStr)) {
+        try {
+          return HashedPassword.decode(hashedStr).checkPassword(password);
+        } catch (HashedPassword.DecoderException e) {
+          logger.atSevere().log("DecoderException for user %s: %s ", username, e.getMessage());
+          return false;
+        }
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
new file mode 100644
index 0000000..0e469e3
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -0,0 +1,13 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//lib:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java
new file mode 100644
index 0000000..a93192a
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids.testing;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.notes.NoteMap;
+
+@FunctionalInterface
+public interface ExternalIdInserter {
+  public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
new file mode 100644
index 0000000..b8040f7
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids.testing;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import java.io.IOException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Common methods for dealing with external IDs in tests. */
+public class ExternalIdTestUtil {
+
+  public static String insertExternalIdWithoutAccountId(
+      Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
+          ObjectId noteId = extId.key().sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          c.unset("externalId", extId.key().get(), "accountId");
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
+          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithInvalidConfig(
+      Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "bad-config".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithEmptyNote(
+      Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private static String insertExternalId(
+      Repository repo, RevWalk rw, PersonIdent ident, ExternalIdInserter extIdInserter)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setMessage("Update external IDs");
+      cb.setTreeId(noteMap.writeTree(ins));
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      if (!rev.equals(ObjectId.zeroId())) {
+        cb.setParentId(rev);
+      } else {
+        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+      }
+      if (cb.getTreeId() == null) {
+        if (rev.equals(ObjectId.zeroId())) {
+          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
+        } else {
+          RevCommit p = rw.parseCommit(rev);
+          cb.setTreeId(p.getTree()); // Copy tree from parent.
+        }
+      }
+      ObjectId commitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+      u.setExpectedOldObjectId(rev);
+      u.setNewObjectId(commitId);
+      RefUpdate.Result res = u.update();
+      switch (res) {
+        case NEW:
+        case FAST_FORWARD:
+        case NO_CHANGE:
+        case RENAMED:
+        case FORCED:
+          break;
+        case LOCK_FAILURE:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException("Updating external IDs failed with " + res);
+      }
+      return noteId.getName();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index 89b1370..0275c79 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -9,17 +9,17 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 673f5ae..7fa9767 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -240,7 +240,7 @@
   @Override
   public AccountDetailInfo detail() throws RestApiException {
     try {
-      return getDetail.apply(account);
+      return getDetail.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get detail", e);
     }
@@ -248,8 +248,12 @@
 
   @Override
   public boolean getActive() throws RestApiException {
-    Response<String> result = getActive.apply(account);
-    return result.statusCode() == SC_OK && result.value().equals("ok");
+    try {
+      Response<String> result = getActive.apply(account);
+      return result.statusCode() == SC_OK && result.value().equals("ok");
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get active", e);
+    }
   }
 
   @Override
@@ -274,7 +278,7 @@
   @Override
   public GeneralPreferencesInfo getPreferences() throws RestApiException {
     try {
-      return getPreferences.apply(account);
+      return getPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get preferences", e);
     }
@@ -283,7 +287,7 @@
   @Override
   public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
-      return setPreferences.apply(account, in);
+      return setPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set preferences", e);
     }
@@ -292,7 +296,7 @@
   @Override
   public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
     try {
-      return getDiffPreferences.apply(account);
+      return getDiffPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query diff preferences", e);
     }
@@ -301,7 +305,7 @@
   @Override
   public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
-      return setDiffPreferences.apply(account, in);
+      return setDiffPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set diff preferences", e);
     }
@@ -310,7 +314,7 @@
   @Override
   public EditPreferencesInfo getEditPreferences() throws RestApiException {
     try {
-      return getEditPreferences.apply(account);
+      return getEditPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query edit preferences", e);
     }
@@ -319,7 +323,7 @@
   @Override
   public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
-      return setEditPreferences.apply(account, in);
+      return setEditPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set edit preferences", e);
     }
@@ -328,7 +332,7 @@
   @Override
   public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
     try {
-      return getWatchedProjects.apply(account);
+      return getWatchedProjects.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get watched projects", e);
     }
@@ -338,7 +342,7 @@
   public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
       throws RestApiException {
     try {
-      return postWatchedProjects.apply(account, in);
+      return postWatchedProjects.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot update watched projects", e);
     }
@@ -389,7 +393,7 @@
   public SortedSet<String> getStars(String changeId) throws RestApiException {
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      return starsGet.apply(rsrc);
+      return starsGet.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get stars", e);
     }
@@ -398,7 +402,7 @@
   @Override
   public List<ChangeInfo> getStarredChanges() throws RestApiException {
     try {
-      return stars.list().apply(account);
+      return stars.list().apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get starred changes", e);
     }
@@ -407,7 +411,7 @@
   @Override
   public List<GroupInfo> getGroups() throws RestApiException {
     try {
-      return getGroups.apply(account);
+      return getGroups.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get groups", e);
     }
@@ -416,7 +420,7 @@
   @Override
   public List<EmailInfo> getEmails() throws RestApiException {
     try {
-      return getEmails.apply(account);
+      return getEmails.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get emails", e);
     }
@@ -475,7 +479,7 @@
   @Override
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
-      return getSshKeys.apply(account);
+      return getSshKeys.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list SSH keys", e);
     }
@@ -534,7 +538,7 @@
   @Override
   public List<AgreementInfo> listAgreements() throws RestApiException {
     try {
-      return getAgreements.apply(account);
+      return getAgreements.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get agreements", e);
     }
@@ -563,7 +567,7 @@
   @Override
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
-      return getExternalIds.apply(account);
+      return getExternalIds.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get external IDs", e);
     }
@@ -582,7 +586,7 @@
   public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
       throws RestApiException {
     try {
-      return deleteDraftComments.apply(account, input);
+      return deleteDraftComments.apply(account, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot delete draft comments", e);
     }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 9d29888..012e6ce 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -133,7 +133,7 @@
       myQueryAccounts.setSuggest(true);
       myQueryAccounts.setQuery(r.getQuery());
       myQueryAccounts.setLimit(r.getLimit());
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
@@ -164,7 +164,7 @@
       for (ListAccountsOption option : r.getOptions()) {
         myQueryAccounts.addOption(option);
       }
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
diff --git a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
index 759f60c..f68142f 100644
--- a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
@@ -61,7 +61,7 @@
   @Override
   public EmailInfo get() throws RestApiException {
     try {
-      return get.apply(resource());
+      return get.apply(resource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot read email", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index f027c92..a04be30 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -351,7 +351,7 @@
   @Override
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
-      return changeApi.id(revert.apply(change, in)._number);
+      return changeApi.id(revert.apply(change, in).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot revert change", e);
     }
@@ -401,7 +401,11 @@
 
   @Override
   public String topic() throws RestApiException {
-    return getTopic.apply(change);
+    try {
+      return getTopic.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get topic", e);
+    }
   }
 
   @Override
@@ -418,7 +422,7 @@
   @Override
   public IncludedInInfo includedIn() throws RestApiException {
     try {
-      return includedIn.apply(change);
+      return includedIn.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
@@ -427,7 +431,7 @@
   @Override
   public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
-      return postReviewers.apply(change, in);
+      return postReviewers.apply(change, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot add change reviewer", e);
     }
@@ -448,7 +452,9 @@
     try {
       suggestReviewers.setQuery(r.getQuery());
       suggestReviewers.setLimit(r.getLimit());
-      return suggestReviewers.apply(change);
+      suggestReviewers.setExcludeGroups(r.getExcludeGroups());
+      suggestReviewers.setReviewerState(r.getReviewerState());
+      return suggestReviewers.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested reviewers", e);
     }
@@ -457,7 +463,7 @@
   @Override
   public List<ReviewerInfo> reviewers() throws RestApiException {
     try {
-      return listReviewers.apply(change);
+      return listReviewers.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve reviewers", e);
     }
@@ -512,7 +518,7 @@
   @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
-      return putAssignee.apply(change, input);
+      return putAssignee.apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set assignee", e);
     }
@@ -550,7 +556,7 @@
   @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
-      return listComments.apply(change);
+      return listComments.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get comments", e);
     }
@@ -568,7 +574,7 @@
   @Override
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
-      return listChangeRobotComments.apply(change);
+      return listChangeRobotComments.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get robot comments", e);
     }
@@ -577,7 +583,7 @@
   @Override
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
-      return listDrafts.apply(change);
+      return listDrafts.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get drafts", e);
     }
@@ -671,7 +677,7 @@
     try {
       GetPureRevert getPureRevert = getPureRevertProvider.get();
       getPureRevert.setClaimedOriginal(claimedOriginal);
-      return getPureRevert.apply(change);
+      return getPureRevert.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot compute pure revert", e);
     }
@@ -680,7 +686,7 @@
   @Override
   public List<ChangeMessageInfo> messages() throws RestApiException {
     try {
-      return changeMessages.list().apply(change);
+      return changeMessages.list().apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list change messages", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index ffc6524..7f0feba 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -221,7 +221,7 @@
   public String getCommitMessage() throws RestApiException {
     try {
       try (BinaryResult binaryResult =
-          getChangeEditCommitMessageProvider.get().apply(changeResource)) {
+          getChangeEditCommitMessageProvider.get().apply(changeResource).value()) {
         return binaryResult.asString();
       }
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
index 14310e8..490ec5b 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
@@ -48,7 +48,7 @@
   @Override
   public ChangeMessageInfo get() throws RestApiException {
     try {
-      return getChangeMessage.apply(changeMessageResource);
+      return getChangeMessage.apply(changeMessageResource).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change message", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index acff137..b9635fb 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -29,7 +30,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
@@ -92,7 +92,7 @@
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(new Change.Id(out._number)));
+      return api.create(changes.parse(Change.id(out._number)));
     } catch (Exception e) {
       throw asRestApiException("Cannot create change", e);
     }
@@ -127,7 +127,7 @@
     dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
 
     try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE);
+      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
       if (result.isEmpty()) {
         return ImmutableList.of();
       }
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index 418187d..c5fcab1 100644
--- a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -46,7 +46,7 @@
   @Override
   public CommentInfo get() throws RestApiException {
     try {
-      return getComment.apply(comment);
+      return getComment.apply(comment).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve comment", e);
     }
@@ -55,7 +55,7 @@
   @Override
   public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
     try {
-      return deleteComment.apply(comment, input);
+      return deleteComment.apply(comment, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot delete comment", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 4d26b11..f6eb3c5 100644
--- a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -54,7 +54,7 @@
   @Override
   public CommentInfo get() throws RestApiException {
     try {
-      return getDraft.apply(draft);
+      return getDraft.apply(draft).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve draft", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index 883ab99..c506b2e 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -63,7 +63,7 @@
   @Override
   public BinaryResult content() throws RestApiException {
     try {
-      return getContent.apply(file);
+      return getContent.apply(file).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve file content", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 11536cb..2174ef0 100644
--- a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -54,7 +54,7 @@
   @Override
   public Map<String, Short> votes() throws RestApiException {
     try {
-      return listVotes.apply(reviewer);
+      return listVotes.apply(reviewer).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list votes", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 2df7ae6..01dfe36 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder.ListMultimapBuilder;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -54,7 +55,6 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
@@ -254,7 +254,7 @@
   public BinaryResult submitPreview(String format) throws RestApiException {
     try {
       submitPreview.setFormat(format);
-      return submitPreview.apply(revision);
+      return submitPreview.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get submit preview", e);
     }
@@ -263,7 +263,7 @@
   @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
-      return changes.id(rebase.apply(revision, in)._number);
+      return changes.id(rebase.apply(revision, in).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase ps", e);
     }
@@ -282,7 +282,7 @@
   @Override
   public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
     try {
-      return changes.id(cherryPick.apply(revision, in)._number);
+      return changes.id(cherryPick.apply(revision, in).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot cherry pick", e);
     }
@@ -291,7 +291,7 @@
   @Override
   public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
     try {
-      return cherryPick.apply(revision, in);
+      return cherryPick.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot cherry pick", e);
     }
@@ -336,7 +336,7 @@
   @Override
   public MergeableInfo mergeable() throws RestApiException {
     try {
-      return mergeable.apply(revision);
+      return mergeable.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check mergeability", e);
     }
@@ -346,7 +346,7 @@
   public MergeableInfo mergeableOtherBranches() throws RestApiException {
     try {
       mergeable.setOtherBranches(true);
-      return mergeable.apply(revision);
+      return mergeable.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check mergeability", e);
     }
@@ -400,7 +400,7 @@
   @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
-      return listComments.apply(revision);
+      return listComments.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve comments", e);
     }
@@ -409,7 +409,7 @@
   @Override
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
-      return listRobotComments.apply(revision);
+      return listRobotComments.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve robot comments", e);
     }
@@ -427,7 +427,7 @@
   @Override
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
-      return listDrafts.apply(revision);
+      return listDrafts.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve drafts", e);
     }
@@ -476,7 +476,7 @@
       // Reread change to pick up new notes refs.
       return changes
           .id(revision.getChange().getId().get())
-          .revision(revision.getPatchSet().getId().get())
+          .revision(revision.getPatchSet().id().get())
           .draft(id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create draft", e);
@@ -504,7 +504,7 @@
   @Override
   public BinaryResult patch() throws RestApiException {
     try {
-      return getPatch.apply(revision);
+      return getPatch.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get patch", e);
     }
@@ -513,7 +513,7 @@
   @Override
   public BinaryResult patch(String path) throws RestApiException {
     try {
-      return getPatch.setPath(path).apply(revision);
+      return getPatch.setPath(path).apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get patch", e);
     }
@@ -531,7 +531,7 @@
   @Override
   public SubmitType submitType() throws RestApiException {
     try {
-      return getSubmitType.apply(revision);
+      return getSubmitType.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get submit type", e);
     }
@@ -540,16 +540,16 @@
   @Override
   public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
     try {
-      return testSubmitType.apply(revision, in);
+      return testSubmitType.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot test submit type", e);
     }
   }
 
   @Override
-  public List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
+  public TestSubmitRuleInfo testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
     try {
-      return testSubmitRule.get().apply(revision, in);
+      return testSubmitRule.get().apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot test submit rule", e);
     }
@@ -575,7 +575,7 @@
   @Override
   public RelatedChangesInfo related() throws RestApiException {
     try {
-      return getRelated.apply(revision);
+      return getRelated.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get related changes", e);
     }
@@ -587,20 +587,20 @@
         ListMultimapBuilder.treeKeys().arrayListValues().build();
     try {
       Iterable<PatchSetApproval> approvals =
-          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().getId(), null, null);
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
       AccountLoader accountLoader =
           accountLoaderFactory.create(
               EnumSet.of(
                   FillOptions.ID, FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
       for (PatchSetApproval approval : approvals) {
-        String label = approval.getLabel();
+        String label = approval.label();
         ApprovalInfo info =
             new ApprovalInfo(
-                approval.getAccountId().get(),
-                Integer.valueOf(approval.getValue()),
+                approval.accountId().get(),
+                Integer.valueOf(approval.value()),
                 null,
-                approval.getTag(),
-                approval.getGranted());
+                approval.tag().orElse(null),
+                approval.granted());
         accountLoader.put(info);
         result.get(label).add(info);
       }
@@ -624,7 +624,11 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(revision);
+    try {
+      return getDescription.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get description", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
index 8cad507..49c2d49 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -47,7 +47,7 @@
   @Override
   public Map<String, Short> votes() throws RestApiException {
     try {
-      return listVotes.apply(reviewer);
+      return listVotes.apply(reviewer).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list votes", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
index 37a56fe..ec13061 100644
--- a/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -41,7 +41,7 @@
   @Override
   public RobotCommentInfo get() throws RestApiException {
     try {
-      return getComment.apply(comment);
+      return getComment.apply(comment).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve robot comment", e);
     }
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index 6e78be2..ab40ec8 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -83,7 +83,7 @@
   @Override
   public ServerInfo getInfo() throws RestApiException {
     try {
-      return getServerInfo.apply(new ConfigResource());
+      return getServerInfo.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get server info", e);
     }
@@ -92,7 +92,7 @@
   @Override
   public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
     try {
-      return getPreferences.apply(new ConfigResource());
+      return getPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default general preferences", e);
     }
@@ -102,7 +102,7 @@
   public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
       throws RestApiException {
     try {
-      return setPreferences.apply(new ConfigResource(), in);
+      return setPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default general preferences", e);
     }
@@ -111,7 +111,7 @@
   @Override
   public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
     try {
-      return getDiffPreferences.apply(new ConfigResource());
+      return getDiffPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default diff preferences", e);
     }
@@ -121,7 +121,7 @@
   public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
       throws RestApiException {
     try {
-      return setDiffPreferences.apply(new ConfigResource(), in);
+      return setDiffPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default diff preferences", e);
     }
@@ -130,7 +130,7 @@
   @Override
   public EditPreferencesInfo getDefaultEditPreferences() throws RestApiException {
     try {
-      return getEditPreferences.apply(new ConfigResource());
+      return getEditPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default edit preferences", e);
     }
@@ -140,7 +140,7 @@
   public EditPreferencesInfo setDefaultEditPreferences(EditPreferencesInfo in)
       throws RestApiException {
     try {
-      return setEditPreferences.apply(new ConfigResource(), in);
+      return setEditPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default edit preferences", e);
     }
@@ -149,14 +149,18 @@
   @Override
   public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
     try {
-      return checkConsistency.get().apply(new ConfigResource(), in);
+      return checkConsistency.get().apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check consistency", e);
     }
   }
 
   @Override
-  public List<TopMenu.MenuEntry> topMenus() {
-    return listTopMenus.apply(new ConfigResource()).value();
+  public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
+    try {
+      return listTopMenus.apply(new ConfigResource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get top menus", e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index b70a029..bb04ab4 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -119,7 +119,7 @@
   @Override
   public GroupInfo get() throws RestApiException {
     try {
-      return getGroup.apply(rsrc);
+      return getGroup.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve group", e);
     }
@@ -128,7 +128,7 @@
   @Override
   public GroupInfo detail() throws RestApiException {
     try {
-      return getDetail.apply(rsrc);
+      return getDetail.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve group", e);
     }
@@ -136,7 +136,11 @@
 
   @Override
   public String name() throws RestApiException {
-    return getName.apply(rsrc);
+    try {
+      return getName.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group name", e);
+    }
   }
 
   @Override
@@ -153,7 +157,7 @@
   @Override
   public GroupInfo owner() throws RestApiException {
     try {
-      return getOwner.apply(rsrc);
+      return getOwner.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get group owner", e);
     }
@@ -172,7 +176,11 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(rsrc);
+    try {
+      return getDescription.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group description", e);
+    }
   }
 
   @Override
@@ -188,7 +196,11 @@
 
   @Override
   public GroupOptionsInfo options() throws RestApiException {
-    return getOptions.apply(rsrc);
+    try {
+      return getOptions.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group options", e);
+    }
   }
 
   @Override
@@ -209,7 +221,7 @@
   public List<AccountInfo> members(boolean recursive) throws RestApiException {
     listMembers.setRecursive(recursive);
     try {
-      return listMembers.apply(rsrc);
+      return listMembers.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list group members", e);
     }
@@ -236,7 +248,7 @@
   @Override
   public List<GroupInfo> includedGroups() throws RestApiException {
     try {
-      return listSubgroups.apply(rsrc);
+      return listSubgroups.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list subgroups", e);
     }
@@ -263,7 +275,7 @@
   @Override
   public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
     try {
-      return getAuditLog.apply(rsrc);
+      return getAuditLog.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get audit log", e);
     }
diff --git a/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index bae75db..95dcad4 100644
--- a/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -98,7 +98,7 @@
           .currentUser()
           .checkAny(GlobalPermission.fromAnnotation(createGroup.getClass()));
       GroupInfo info =
-          createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.name), in);
+          createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.name), in).value();
       return id(info.id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create group " + in.name, e);
@@ -141,7 +141,7 @@
 
     if (req.getUser() != null) {
       try {
-        list.setUser(accountResolver.resolve(req.getUser()).asUnique().getAccount().getId());
+        list.setUser(accountResolver.resolve(req.getUser()).asUnique().account().id());
       } catch (Exception e) {
         throw asRestApiException("Error looking up user " + req.getUser(), e);
       }
@@ -154,7 +154,7 @@
     list.setMatchRegex(req.getRegex());
     list.setSuggest(req.getSuggest());
     try {
-      return list.apply(tlr);
+      return list.apply(tlr).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list groups", e);
     }
@@ -184,7 +184,7 @@
       for (ListGroupsOption option : r.getOptions()) {
         myQueryGroups.addOption(option);
       }
-      return myQueryGroups.apply(TopLevelResource.INSTANCE);
+      return myQueryGroups.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query groups", e);
     }
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 71f7832..3932177 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
@@ -53,7 +55,11 @@
 
   @Override
   public PluginInfo get() throws RestApiException {
-    return getStatus.apply(resource);
+    try {
+      return getStatus.apply(resource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get status", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
index e570655..c275093 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins;
@@ -27,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.SortedMap;
 
 @Singleton
@@ -59,7 +60,11 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, PluginInfo> getAsMap() throws RestApiException {
-        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE);
+        try {
+          return listProvider.get().request(this).apply(TopLevelResource.INSTANCE).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list plugins", e);
+        }
       }
     };
   }
@@ -87,8 +92,8 @@
       Response<PluginInfo> created =
           installProvider.get().setName(name).apply(TopLevelResource.INSTANCE, input);
       return pluginApi.create(plugins.parse(created.value().id));
-    } catch (IOException e) {
-      throw new RestApiException("could not install plugin", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot install plugin", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index b3506fc..c7cca6f 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -90,7 +90,7 @@
   @Override
   public BranchInfo get() throws RestApiException {
     try {
-      return getBranch.apply(resource());
+      return getBranch.apply(resource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot read branch", e);
     }
@@ -109,7 +109,7 @@
   public BinaryResult file(String path) throws RestApiException {
     try {
       FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
-      return getContent.apply(resource);
+      return getContent.apply(resource).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve file", e);
     }
@@ -118,9 +118,9 @@
   @Override
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
-      return getReflog.apply(resource());
-    } catch (IOException | PermissionBackendException e) {
-      throw new RestApiException("Cannot retrieve reflog", e);
+      return getReflog.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve reflog", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index d7c9bc7..25e56fe 100644
--- a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -43,7 +45,11 @@
 
   @Override
   public ProjectInfo get(boolean recursive) throws RestApiException {
-    getChildProject.setRecursive(recursive);
-    return getChildProject.apply(rsrc);
+    try {
+      getChildProject.setRecursive(recursive);
+      return getChildProject.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get child project", e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index 0a3b236..5c7921a 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -58,7 +58,7 @@
   @Override
   public CommitInfo get() throws RestApiException {
     try {
-      return getCommit.apply(commitResource);
+      return getCommit.apply(commitResource).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get commit info", e);
     }
@@ -67,7 +67,7 @@
   @Override
   public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
     try {
-      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+      return changes.id(cherryPickCommit.apply(commitResource, input).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot cherry pick", e);
     }
@@ -76,7 +76,7 @@
   @Override
   public IncludedInInfo includedIn() throws RestApiException {
     try {
-      return includedIn.apply(commitResource);
+      return includedIn.apply(commitResource).value();
     } catch (Exception e) {
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
index c44f5bb..61736f6 100644
--- a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -67,8 +67,8 @@
   @Override
   public DashboardInfo get(boolean inherited) throws RestApiException {
     try {
-      return get.get().setInherited(inherited).apply(resource());
-    } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
+      return get.get().setInherited(inherited).apply(resource()).value();
+    } catch (Exception e) {
       throw asRestApiException("Cannot read dashboard", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 354331e..1ac905d 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -69,6 +70,7 @@
 import com.google.gerrit.server.restapi.project.GetHead;
 import com.google.gerrit.server.restapi.project.GetParent;
 import com.google.gerrit.server.restapi.project.Index;
+import com.google.gerrit.server.restapi.project.IndexChanges;
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListTags;
@@ -124,6 +126,7 @@
   private final GetParent getParent;
   private final SetParent setParent;
   private final Index index;
+  private final IndexChanges indexChanges;
 
   @AssistedInject
   ProjectApiImpl(
@@ -158,6 +161,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -192,6 +196,7 @@
         getParent,
         setParent,
         index,
+        indexChanges,
         null);
   }
 
@@ -228,6 +233,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -262,6 +268,7 @@
         getParent,
         setParent,
         index,
+        indexChanges,
         name);
   }
 
@@ -298,6 +305,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -332,6 +340,7 @@
     this.setParent = setParent;
     this.name = name;
     this.index = index;
+    this.indexChanges = indexChanges;
   }
 
   @Override
@@ -368,13 +377,17 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(checkExists());
+    try {
+      return getDescription.apply(checkExists()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get description", e);
+    }
   }
 
   @Override
   public ProjectAccessInfo access() throws RestApiException {
     try {
-      return getAccess.apply(checkExists());
+      return getAccess.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get access rights", e);
     }
@@ -383,7 +396,7 @@
   @Override
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
-      return setAccess.apply(checkExists(), p);
+      return setAccess.apply(checkExists(), p).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot put access rights", e);
     }
@@ -401,7 +414,7 @@
   @Override
   public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
     try {
-      return checkAccess.apply(checkExists(), in);
+      return checkAccess.apply(checkExists(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check access rights", e);
     }
@@ -410,7 +423,7 @@
   @Override
   public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
     try {
-      return check.apply(checkExists(), in);
+      return check.apply(checkExists(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check project", e);
     }
@@ -427,13 +440,17 @@
 
   @Override
   public ConfigInfo config() throws RestApiException {
-    return getConfig.apply(checkExists());
+    try {
+      return getConfig.apply(checkExists()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get config", e);
+    }
   }
 
   @Override
   public ConfigInfo config(ConfigInput in) throws RestApiException {
     try {
-      return putConfig.apply(checkExists(), in);
+      return putConfig.apply(checkExists(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list tags", e);
     }
@@ -445,7 +462,7 @@
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
-          return listBranches.get().request(this).apply(checkExists());
+          return listBranches.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot list branches", e);
         }
@@ -459,7 +476,7 @@
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
-          return listTags.get().request(this).apply(checkExists());
+          return listTags.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot list tags", e);
         }
@@ -475,7 +492,7 @@
   @Override
   public List<ProjectInfo> children(boolean recursive) throws RestApiException {
     try {
-      return children.list().withRecursive(recursive).apply(checkExists());
+      return children.list().withRecursive(recursive).apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list children", e);
     }
@@ -484,7 +501,7 @@
   @Override
   public List<ProjectInfo> children(int limit) throws RestApiException {
     try {
-      return children.list().withLimit(limit).apply(checkExists());
+      return children.list().withLimit(limit).apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list children", e);
     }
@@ -574,7 +591,7 @@
       @Override
       public List<DashboardInfo> get() throws RestApiException {
         try {
-          List<?> r = listDashboards.get().apply(checkExists());
+          List<?> r = listDashboards.get().apply(checkExists()).value();
           if (r.isEmpty()) {
             return Collections.emptyList();
           }
@@ -592,7 +609,7 @@
   @Override
   public String head() throws RestApiException {
     try {
-      return getHead.apply(checkExists());
+      return getHead.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get HEAD", e);
     }
@@ -612,7 +629,7 @@
   @Override
   public String parent() throws RestApiException {
     try {
-      return getParent.apply(checkExists());
+      return getParent.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get parent", e);
     }
@@ -640,6 +657,15 @@
     }
   }
 
+  @Override
+  public void indexChanges() throws RestApiException {
+    try {
+      indexChanges.apply(checkExists(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index changes", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index 46a22c0..66d0224 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
@@ -45,7 +45,7 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(n));
+    Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(n));
     if (!group.isPresent()) {
       throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 2dd0c7a..20e8441 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -18,7 +18,7 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
@@ -53,7 +53,7 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    AccountGroup.UUID uuid = new AccountGroup.UUID(n);
+    AccountGroup.UUID uuid = AccountGroup.uuid(n);
     if (groupBackend.handles(uuid)) {
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 37e52f4..73a970b 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -17,10 +17,10 @@
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
@@ -65,7 +65,7 @@
     Account.Id accountId;
     try {
       try {
-        accountId = accountResolver.resolve(token).asUnique().getAccount().getId();
+        accountId = accountResolver.resolve(token).asUnique().account().id();
       } catch (UnprocessableEntityException e) {
         switch (authType) {
           case HTTP_LDAP:
diff --git a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index ceba3e6..448c654 100644
--- a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -18,10 +18,10 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -62,8 +62,8 @@
 
     try {
       Change.Key key = Change.Key.parse(tokens.get(2));
-      Project.NameKey project = new Project.NameKey(tokens.get(0));
-      Branch.NameKey branch = new Branch.NameKey(project, tokens.get(1));
+      Project.NameKey project = Project.nameKey(tokens.get(0));
+      BranchNameKey branch = BranchNameKey.create(project, tokens.get(1));
       List<ChangeData> changes = queryProvider.get().byBranchKey(branch, key);
       if (!changes.isEmpty()) {
         if (changes.size() > 1) {
diff --git a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
index 4581fe0..8b7cbd6 100644
--- a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.PatchSet;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import org.kohsuke.args4j.CmdLineException;
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index e3af82b..61dbd2c 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -17,8 +17,8 @@
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -72,7 +72,7 @@
     }
 
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-    Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
+    Project.NameKey nameKey = Project.nameKey(nameWithoutSuffix);
 
     ProjectState state;
     try {
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 425e22a..2a5d868 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.audit;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.AuditEvent;
 import com.google.gerrit.server.audit.group.GroupAuditListener;
 import com.google.gerrit.server.audit.group.GroupMemberAuditEvent;
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index f666746..95929d3 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -9,15 +9,9 @@
     resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/ioutil",
-        "//java/com/google/gerrit/server/logging",
-        "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/util/cli",
-        "//java/com/google/gerrit/util/ssl",
-        "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
         "//lib:automaton",
@@ -50,11 +44,13 @@
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-archive",
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
         "//lib/auto:auto-value",
@@ -71,8 +67,6 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jsoup",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
diff --git a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
index ae26e12..252a1e2 100644
--- a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.audit.group;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 
 /** An audit event for groups. */
@@ -23,14 +23,14 @@
   /**
    * Gets the acting user who is updating the group.
    *
-   * @return the {@link com.google.gerrit.reviewdb.client.Account.Id} of the acting user.
+   * @return the {@link com.google.gerrit.entities.Account.Id} of the acting user.
    */
   Account.Id getActor();
 
   /**
-   * Gets the {@link com.google.gerrit.reviewdb.client.AccountGroup.UUID} of the updated group.
+   * Gets the {@link com.google.gerrit.entities.AccountGroup.UUID} of the updated group.
    *
-   * @return the {@link com.google.gerrit.reviewdb.client.AccountGroup.UUID} of the updated group.
+   * @return the {@link com.google.gerrit.entities.AccountGroup.UUID} of the updated group.
    */
   AccountGroup.UUID getUpdatedGroup();
 
diff --git a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
index a5c11bc..eccfbf4 100644
--- a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
@@ -16,8 +16,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 
 @AutoValue
diff --git a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
index 0d5b26f..0fe3962 100644
--- a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
@@ -16,8 +16,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 
 @AutoValue
diff --git a/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index c06c66b..2f8886b 100644
--- a/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.PasswordVerifier;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -55,14 +56,14 @@
 
     AccountState who = accountCache.getByUsername(username).orElseThrow(UnknownUserException::new);
 
-    if (!who.getAccount().isActive()) {
+    if (!who.account().isActive()) {
       throw new UserNotAllowedException(
           "Authentication failed for "
               + username
               + ": account inactive or not provisioned in Gerrit");
     }
 
-    if (!who.checkPassword(req.getPassword().get(), username)) {
+    if (!PasswordVerifier.checkPassword(who.externalIds(), username, req.getPassword().get())) {
       throw new InvalidCredentialsException();
     }
     return new AuthUser(AuthUser.UUID.create(username), username);
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index 4b14c38..5c6b391 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthenticationFailedException;
 import com.google.gerrit.server.auth.NoSuchUserException;
@@ -321,7 +321,7 @@
 
     final Set<AccountGroup.UUID> actual = new HashSet<>();
     for (String dn : groupDNs) {
-      actual.add(new AccountGroup.UUID(LDAP_UUID + dn));
+      actual.add(AccountGroup.uuid(LDAP_UUID + dn));
     }
 
     if (actual.isEmpty()) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 87a4abf..1d85a5e 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
@@ -92,7 +92,7 @@
   private static GroupReference groupReference(ParameterizedString p, LdapQuery.Result res)
       throws NamingException {
     return new GroupReference(
-        new AccountGroup.UUID(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
+        AccountGroup.uuid(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
   private static String cnFor(String dn) {
@@ -164,7 +164,7 @@
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(name);
+    AccountGroup.UUID uuid = AccountGroup.uuid(name);
     if (isLdapUUID(uuid)) {
       GroupDescription.Basic g = get(uuid);
       if (g == null) {
@@ -179,7 +179,7 @@
 
   @Override
   public GroupMembership membershipsOf(IdentifiedUser user) {
-    String id = findId(user.state().getExternalIds());
+    String id = findId(user.state().externalIds());
     if (id == null) {
       return GroupMembership.EMPTY;
     }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
index f5406c2..a6aa2f6 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.auth.ldap;
 
 import com.google.common.cache.LoadingCache;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/java/com/google/gerrit/server/auth/ldap/LdapModule.java
index 3fbf049..092b5ac 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapModule.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.auth.ldap;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.cache.CacheModule;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index b047488..c53ba83 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -22,10 +22,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
@@ -301,7 +302,7 @@
 
   @Override
   public void onCreateAccount(AuthRequest who, Account account) {
-    usernameCache.put(who.getLocalUser(), Optional.of(account.getId()));
+    usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
   }
 
   @Override
@@ -353,7 +354,9 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading account for username %s", username)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading account for username", Metadata.builder().username(username).build())) {
         return externalIds
             .get(ExternalId.Key.create(SCHEME_GERRIT, username))
             .map(ExternalId::accountId);
@@ -372,7 +375,9 @@
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
       try (TraceTimer timer =
-          TraceContext.newTimer("Loading group for member with username %s", username)) {
+          TraceContext.newTimer(
+              "Loading group for member with username",
+              Metadata.builder().username(username).build())) {
         final DirContext ctx = helper.open();
         try {
           return helper.queryForGroups(ctx, username, null);
@@ -393,7 +398,9 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading groupDn %s", groupDn)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading groupDn", Metadata.builder().authDomainName(groupDn).build())) {
         final DirContext ctx = helper.open();
         try {
           Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
index 2cef62d..944bd44 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -17,11 +17,11 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 0980116..f58b0f7 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -17,17 +17,18 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.serialize.IntegerCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +46,9 @@
       protected void configure() {
         persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
             .version(1)
-            .keySerializer(new IntKeyCacheSerializer<>(Account.Id::new))
+            .keySerializer(
+                CacheSerializer.convert(
+                    IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
             .valueSerializer(new Serializer());
       }
     };
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 063ddbc..12194e7 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
@@ -33,7 +34,8 @@
 
 @Singleton
 public class CacheMetrics {
-  private static final Field<String> F_NAME = Field.ofString("cache_name");
+  private static final Field<String> F_NAME =
+      Field.ofString("cache_name", Metadata.Builder::cacheName).build();
 
   @Inject
   public CacheMetrics(
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index a191f75..5e64aa7 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -14,8 +14,8 @@
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
+        "//lib:jgit",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 3732e37..ef4e44c 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -237,7 +238,9 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading value for %s from cache", key)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading value from cache", Metadata.builder().cacheKey(key.toString()).build())) {
         if (store.mightContain(key)) {
           ValueHolder<V> h = store.getIfPresent(key);
           if (h != null) {
diff --git a/java/com/google/gerrit/server/cache/mem/BUILD b/java/com/google/gerrit/server/cache/mem/BUILD
index bc5b66a..a666df7 100644
--- a/java/com/google/gerrit/server/cache/mem/BUILD
+++ b/java/com/google/gerrit/server/cache/mem/BUILD
@@ -11,7 +11,7 @@
         "//lib:caffeine",
         "//lib:caffeine-guava",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
index 76dcbb1..aa9106b 100644
--- a/java/com/google/gerrit/server/cache/serialize/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -6,10 +6,10 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gwtorm",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 2d41f2c..5377fc1 100644
--- a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache.serialize;
 
+import com.google.common.base.Converter;
+
 /**
  * Interface for serializing/deserializing a type to/from a persistent cache.
  *
@@ -22,6 +24,27 @@
  */
 public interface CacheSerializer<T> {
   /**
+   * Convert a serializer of one type to another type using a {@link Converter}.
+   *
+   * @param delegate underlying serializer.
+   * @param converter converter between an arbitrary type {@code T} and {@code delegate}'s type.
+   * @return serializer of type {@code T}.
+   */
+  static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
+    return new CacheSerializer<T>() {
+      @Override
+      public byte[] serialize(T object) {
+        return delegate.serialize(converter.convert(object));
+      }
+
+      @Override
+      public T deserialize(byte[] in) {
+        return converter.reverse().convert(delegate.deserialize(in));
+      }
+    };
+  }
+
+  /**
    * Serializes the object to a new byte array.
    *
    * @param object object to serialize.
diff --git a/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
deleted file mode 100644
index 85530f4..0000000
--- a/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache.serialize;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gwtorm.client.IntKey;
-import java.util.function.Function;
-
-public class IntKeyCacheSerializer<K extends IntKey<?>> implements CacheSerializer<K> {
-  private final Function<Integer, K> factory;
-
-  public IntKeyCacheSerializer(Function<Integer, K> factory) {
-    this.factory = requireNonNull(factory);
-  }
-
-  @Override
-  public byte[] serialize(K object) {
-    return IntegerCacheSerializer.INSTANCE.serialize(object.get());
-  }
-
-  @Override
-  public K deserialize(byte[] in) {
-    return factory.apply(IntegerCacheSerializer.INSTANCE.deserialize(in));
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
index 500875d..7c0f84f 100644
--- a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.cache.serialize;
 
-import org.eclipse.jgit.lib.Constants;
+import com.google.gerrit.git.ObjectIds;
 import org.eclipse.jgit.lib.ObjectId;
 
 public enum ObjectIdCacheSerializer implements CacheSerializer<ObjectId> {
@@ -22,14 +22,14 @@
 
   @Override
   public byte[] serialize(ObjectId object) {
-    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    byte[] buf = new byte[ObjectIds.LEN];
     object.copyRawTo(buf, 0);
     return buf;
   }
 
   @Override
   public ObjectId deserialize(byte[] in) {
-    if (in == null || in.length != Constants.OBJECT_ID_LENGTH) {
+    if (in == null || in.length != ObjectIds.LEN) {
       throw new IllegalArgumentException("Failed to deserialize ObjectId");
     }
     return ObjectId.fromRaw(in);
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
index eb946a9..22654e5 100644
--- a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
 
+import com.google.gerrit.git.ObjectIds;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -35,7 +35,7 @@
     return new ObjectIdConverter();
   }
 
-  private final byte[] buf = new byte[OBJECT_ID_LENGTH];
+  private final byte[] buf = new byte[ObjectIds.LEN];
 
   private ObjectIdConverter() {}
 
@@ -46,10 +46,7 @@
 
   public ObjectId fromByteString(ByteString in) {
     checkArgument(
-        in.size() == OBJECT_ID_LENGTH,
-        "expected ByteString of length %s: %s",
-        OBJECT_ID_LENGTH,
-        in);
+        in.size() == ObjectIds.LEN, "expected ByteString of length %s: %s", ObjectIds.LEN, in);
     in.copyTo(buf, 0);
     return ObjectId.fromRaw(buf);
   }
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 5ee5bc7..eb6e8d7 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -17,10 +17,10 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -112,7 +112,7 @@
     try {
       ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
       if (accountState != null) {
-        cm.setFrom(accountState.getAccount().getId());
+        cm.setFrom(accountState.account().id());
       }
       cm.setChangeMessage(message.getMessage(), ctx.getWhen());
       cm.setNotify(notify);
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 6f46498..1bc1fad 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -17,9 +17,9 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -40,7 +40,10 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  private final ChangeQueryBuilder queryBuilder;
+  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
+  // index collection, that is only provided when multiversion index module is started.
+  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
+  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -49,11 +52,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      ChangeQueryBuilder queryBuilder,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilder = queryBuilder;
+    this.queryBuilderProvider = queryBuilderProvider;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -71,7 +74,11 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+          queryProvider
+              .get()
+              .enforceVisibility(false)
+              .query(queryBuilderProvider.get().parse(query))
+              .entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -111,7 +118,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilder.parse(newQuery))
+              .query(queryBuilderProvider.get().parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index fff3274..8da2a90 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -16,9 +16,9 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import java.util.Collection;
 import java.util.Optional;
 
@@ -30,7 +30,8 @@
  * number of reviewed flags is growing without bound. The store must be able handle this data volume
  * efficiently.
  *
- * <p>For a multi-master setup the store must replicate the data between the masters.
+ * <p>For a cluster setups with multiple primary nodes the store must replicate the data between the
+ * primary servers.
  */
 public interface AccountPatchReviewStore {
 
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index d9c5dad..664b84d 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -18,9 +18,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 610290d..87d34a4 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -27,13 +27,13 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
@@ -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) {
@@ -240,7 +246,7 @@
       addReviewersEmail.emailReviewers(
           ctx.getUser().asIdentifiedUser(),
           change,
-          Lists.transform(addedReviewers, PatchSetApproval::getAccountId),
+          Lists.transform(addedReviewers, PatchSetApproval::accountId),
           addedCCs,
           addedReviewersByEmail,
           addedCCsByEmail,
@@ -249,7 +255,7 @@
     if (!addedReviewers.isEmpty()) {
       List<AccountState> reviewers =
           addedReviewers.stream()
-              .map(r -> accountCache.get(r.getAccountId()))
+              .map(r -> accountCache.get(r.accountId()))
               .flatMap(Streams::stream)
               .collect(toList());
       reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
diff --git a/java/com/google/gerrit/server/change/ArchiveFormat.java b/java/com/google/gerrit/server/change/ArchiveFormat.java
index 0316c5f..d895a66 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -35,7 +35,9 @@
   TXZ("application/x-xz", new TxzFormat()),
   ZIP("application/x-zip", new ZipFormat());
 
+  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
   private final ArchiveCommand.Format<?> format;
+
   private final String mimeType;
 
   ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index bc29c5d..e0a72ac4 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
new file mode 100644
index 0000000..a5b7d49
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Allows plugins to contribute a value to the change ETag computation.
+ *
+ * <p>Plugins can affect the result of the get change / get change details REST endpoints by:
+ *
+ * <ul>
+ *   <li>providing plugin defined attributes to {@link
+ *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
+ *       ChangeAttributeFactory})
+ *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
+ *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
+ * </ul>
+ *
+ * <p>If the plugin defined part of {@link com.google.gerrit.extensions.common.ChangeInfo} depends
+ * on plugin specific data, callers that use the change ETags to avoid unneeded recomputations of
+ * ChangeInfos may see outdated plugin attributes and/or outdated submittable information, because a
+ * ChangeInfo is only reloaded if the change ETag changes.
+ *
+ * <p>By implementating this interface plugins can contribute to the change ETag computation and
+ * thus ensure that the ETag changes when the plugin data was changed. This way it is ensured that
+ * callers do not see outdated ChangeInfos.
+ *
+ * @see ChangeResource#getETag()
+ */
+@ExtensionPoint
+public interface ChangeETagComputation {
+  /**
+   * Computes an ETag of plugin-specific data for the given change.
+   *
+   * <p><strong>Note:</strong> Change ETags are computed very frequently and the computation must be
+   * cheap. Take good care to not perform any expensive computations when implementing this.
+   *
+   * <p>If an error is encountered during the ETag computation the plugin can indicate this by
+   * throwing any RuntimeException. In this case no value will be included in the change ETag
+   * computation. This means if the error is transient, the ETag will differ when the computation
+   * succeeds on a follow-up run.
+   *
+   * @param projectName the name of the project that contains the change
+   * @param changeId ID of the change for which the ETag should be computed
+   * @return the ETag
+   */
+  String getETag(Project.NameKey projectName, Change.Id changeId);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 751d4b8..8d2d83d 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -19,17 +19,18 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -91,7 +92,8 @@
             new Description("Total number of API calls per identifier type.")
                 .setRate()
                 .setUnit("requests"),
-            Field.ofEnum(ChangeIdType.class, "change_id_type"));
+            Field.ofEnum(ChangeIdType.class, "change_id_type", Metadata.Builder::changeIdType)
+                .build());
   }
 
   public ChangeNotes findOne(String id) {
@@ -129,7 +131,7 @@
       Integer n = Ints.tryParse(id);
       if (n != null) {
         changeIdCounter.increment(ChangeIdType.NUMERIC_ID);
-        return find(new Change.Id(n));
+        return find(Change.id(n));
       }
     }
 
@@ -138,7 +140,7 @@
     InternalChangeQuery query = queryProvider.get().noFields();
 
     // Try commit hash
-    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
+    if (id.matches("^([0-9a-fA-F]{" + ObjectIds.ABBREV_STR_LEN + "," + ObjectIds.STR_LEN + "})$")) {
       changeIdCounter.increment(ChangeIdType.COMMIT_HASH);
       return asChangeNotes(query.byCommit(id));
     }
@@ -162,7 +164,7 @@
   }
 
   private List<ChangeNotes> fromProjectNumber(String project, int changeNumber) {
-    Change.Id cId = new Change.Id(changeNumber);
+    Change.Id cId = Change.id(changeNumber);
     try {
       return ImmutableList.of(
           changeNotesFactory.createChecked(Project.NameKey.parse(project), cId));
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index a3b5db0..a00f1f8 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
+import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
 import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.util.Objects.requireNonNull;
@@ -30,18 +30,18 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -168,7 +168,7 @@
     this.reviewerAdder = reviewerAdder;
 
     this.changeId = changeId;
-    this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
+    this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
     this.commitId = commitId.copy();
     this.refName = refName;
     this.reviewerInputs = ImmutableList.of();
@@ -185,7 +185,7 @@
             getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
-            new Branch.NameKey(ctx.getProject(), refName),
+            BranchNameKey.create(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
@@ -201,7 +201,7 @@
     rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
-      return new Change.Key(idList.get(idList.size() - 1).trim());
+      return Change.key(idList.get(idList.size() - 1).trim());
     }
     // A Change-Id is generated for the review, but not appended to the commit message.
     // This can happen if requireChangeId is false.
@@ -370,7 +370,7 @@
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setChangeId(change.getKey().get());
     update.setSubjectForCommit("Create change");
-    update.setBranch(change.getDest().get());
+    update.setBranch(change.getDest().branch());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
@@ -419,9 +419,9 @@
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
-              patchSet.getCreatedOn(),
+              patchSet.createdOn(),
               message,
               ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(update, changeMessage);
@@ -446,7 +446,7 @@
                 cm.setNotify(notify);
                 cm.addReviewers(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::getAccountId)
+                        .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 cm.addReviewersByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
@@ -511,14 +511,14 @@
           new CommitReceivedEvent(
               cmd,
               projectState.getProject(),
-              change.getDest().get(),
+              change.getDest().branch(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
                 permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
-                new Branch.NameKey(ctx.getProject(), refName),
+                BranchNameKey.create(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
                 ctx.getRevWalk(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 1008a30..3b7a2c4 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_MERGEABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
@@ -48,6 +49,12 @@
 import com.google.gerrit.common.data.SubmitRecord.Status;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -69,12 +76,6 @@
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -286,7 +287,7 @@
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true, ChangeInfo::new);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, ChangeInfo::new);
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -434,7 +435,7 @@
           info = format(cd, Optional.empty(), false, ChangeInfo::new);
           changeInfos.add(info);
           if (isCacheable) {
-            cache.put(new Change.Id(info._number), info);
+            cache.put(Change.id(info._number), info);
           }
         } catch (RuntimeException e) {
           logger.atWarning().withCause(e).log(
@@ -465,7 +466,7 @@
     Change c = result.change();
     if (c != null) {
       info.project = c.getProject().get();
-      info.branch = c.getDest().getShortName();
+      info.branch = c.getDest().shortName();
       info.topic = c.getTopic();
       info.changeId = c.getKey().get();
       info.subject = c.getSubject();
@@ -513,7 +514,7 @@
 
     Change in = cd.change();
     out.project = in.getProject().get();
-    out.branch = in.getDest().getShortName();
+    out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
@@ -530,10 +531,12 @@
         out.submittable = submittable(cd);
       }
     }
-    Optional<ChangedLines> changedLines = cd.changedLines();
-    if (changedLines.isPresent()) {
-      out.insertions = changedLines.get().insertions;
-      out.deletions = changedLines.get().deletions;
+    if (!has(SKIP_DIFFSTAT)) {
+      Optional<ChangedLines> changedLines = cd.changedLines();
+      if (changedLines.isPresent()) {
+        out.insertions = changedLines.get().insertions;
+        out.deletions = changedLines.get().deletions;
+      }
     }
     out.isPrivate = in.isPrivate() ? true : null;
     out.workInProgress = in.isWorkInProgress() ? true : null;
@@ -670,8 +673,8 @@
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().getGranted();
-    out.submitter = accountLoader.get(s.get().getAccountId());
+    out.submitted = s.get().granted();
+    out.submitter = accountLoader.get(s.get().accountId());
   }
 
   private Collection<ChangeMessageInfo> messages(ChangeData cd) {
@@ -713,7 +716,7 @@
         continue;
       }
       for (ApprovalInfo ai : label.all) {
-        Account.Id id = new Account.Id(ai._accountId);
+        Account.Id id = Account.id(ai._accountId);
 
         if (canRemoveAnyReviewer
             || removeReviewerControl.testRemoveReviewer(
@@ -733,7 +736,7 @@
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
         if (ai._accountId != null) {
-          Account.Id id = new Account.Id(ai._accountId);
+          Account.Id id = Account.id(ai._accountId);
           if (canRemoveAnyReviewer
               || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
             removable.add(id);
@@ -798,7 +801,7 @@
     }
     Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
     for (PatchSet patchSet : src) {
-      map.put(patchSet.getId(), patchSet);
+      map.put(patchSet.id(), patchSet);
     }
     return map;
   }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
new file mode 100644
index 0000000..0db4cea
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+/**
+ * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
+ * {@code id}, for backwards compatibility in stream-events.
+ */
+// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
+// AutoValue method.
+public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
+  @Override
+  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
+    JsonObject obj = new JsonObject();
+    obj.addProperty("id", src.get());
+    return obj;
+  }
+
+  @Override
+  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    JsonElement keyJson = json.getAsJsonObject().get("id");
+    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + keyJson);
+    }
+    String key = keyJson.getAsJsonPrimitive().getAsString();
+    return Change.key(key);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCache.java b/java/com/google/gerrit/server/change/ChangeKindCache.java
index 44da4d6..9bd7ad7 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index c20c0a2..1e14954 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -23,12 +23,12 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -374,13 +374,13 @@
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
-    if (patch.getId().get() > 1) {
+    if (patch.id().get() > 1) {
       try {
         Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
-          if (ps.getId().get() < patch.getId().get()
-              && (ps.getId().get() > priorPs.getId().get() || priorPs == patch)) {
+          if (ps.id().get() < patch.id().get()
+              && (ps.id().get() > priorPs.id().get() || priorPs == patch)) {
             // We only want the previous patch set, so walk until the last one
             priorPs = ps;
           }
@@ -393,22 +393,17 @@
         if (priorPs != patch) {
           kind =
               cache.getChangeKind(
-                  change.project(),
-                  rw,
-                  repoConfig,
-                  ObjectId.fromString(priorPs.getRevision().get()),
-                  ObjectId.fromString(patch.getRevision().get()));
+                  change.project(), rw, repoConfig, priorPs.commitId(), patch.commitId());
         }
       } catch (StorageException e) {
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getId());
+            patch.number(), change.getId());
       }
     }
     logger.atFine().log(
-        "Change kind for patchSet %s of change %s: %s",
-        patch.getPatchSetId(), change.getId(), kind);
+        "Change kind for patchSet %s of change %s: %s", patch.number(), change.getId(), kind);
     return kind;
   }
 
@@ -422,7 +417,7 @@
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
-    if (patch.getId().get() > 1) {
+    if (patch.id().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject());
           RevWalk rw = new RevWalk(repo)) {
         kind =
@@ -432,12 +427,11 @@
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getChangeId());
+            patch.number(), change.getChangeId());
       }
     }
     logger.atFine().log(
-        "Change kind for patchSet %s of change %s: %s",
-        patch.getPatchSetId(), change.getChangeId(), kind);
+        "Change kind for patchSet %s of change %s: %s", patch.number(), change.getChangeId(), kind);
     return kind;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeMessageResource.java b/java/com/google/gerrit/server/change/ChangeMessageResource.java
index 3c9ef34..25f952d 100644
--- a/java/com/google/gerrit/server/change/ChangeMessageResource.java
+++ b/java/com/google/gerrit/server/change/ChangeMessageResource.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.inject.TypeLiteral;
 
 /** A change message resource. */
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 98b728f..8b8ce54 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -21,23 +21,27 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -75,6 +79,7 @@
   private final PermissionBackend permissionBackend;
   private final StarredChangesUtil starredChangesUtil;
   private final ProjectCache projectCache;
+  private final PluginSetContext<ChangeETagComputation> changeETagComputation;
   private final ChangeNotes notes;
   private final CurrentUser user;
 
@@ -86,6 +91,7 @@
       PermissionBackend permissionBackend,
       StarredChangesUtil starredChangesUtil,
       ProjectCache projectCache,
+      PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
     this.accountCache = accountCache;
@@ -94,6 +100,7 @@
     this.permissionBackend = permissionBackend;
     this.starredChangesUtil = starredChangesUtil;
     this.projectCache = projectCache;
+    this.changeETagComputation = changeETagComputation;
     this.notes = notes;
     this.user = user;
   }
@@ -149,7 +156,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil.byChange(notes).stream().map(PatchSet::getUploader).forEach(accounts::add);
+      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
@@ -193,16 +200,32 @@
     for (ProjectState p : projectStateTree) {
       hashObjectId(h, p.getConfig().getRevision(), buf);
     }
+
+    changeETagComputation.runEach(
+        c -> {
+          String pluginETag = c.getETag(notes.getProjectName(), notes.getChangeId());
+          if (pluginETag != null) {
+            h.putString(pluginETag, UTF_8);
+          }
+        });
   }
 
   @Override
   public String getETag() {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    if (user.isIdentifiedUser()) {
-      h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Compute change ETag",
+            Metadata.builder()
+                .changeId(notes.getChangeId().get())
+                .projectName(notes.getProjectName().get())
+                .build())) {
+      Hasher h = Hashing.murmur3_128().newHasher();
+      if (user.isIdentifiedUser()) {
+        h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
+      }
+      prepareETag(h, user);
+      return h.hash().toString();
     }
-    prepareETag(h, user);
-    return h.hash().toString();
   }
 
   private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
@@ -211,9 +234,8 @@
   }
 
   private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
-    h.putInt(accountState.getAccount().getId().get());
-    h.putString(
-        MoreObjects.firstNonNull(accountState.getAccount().getMetaId(), ZERO_ID_STRING), UTF_8);
-    accountState.getExternalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
+    h.putInt(accountState.account().id().get());
+    h.putString(MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING), UTF_8);
+    accountState.externalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeTriplet.java b/java/com/google/gerrit/server/change/ChangeTriplet.java
index e4e6870..1074302 100644
--- a/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.change;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import java.util.Optional;
 
 @AutoValue
@@ -27,8 +27,8 @@
     return format(change.getDest(), change.getKey());
   }
 
-  private static String format(Branch.NameKey branch, Change.Key change) {
-    return branch.getParentKey().get() + "~" + branch.getShortName() + "~" + change.get();
+  private static String format(BranchNameKey branch, Change.Key change) {
+    return branch.project().get() + "~" + branch.shortName() + "~" + change.get();
   }
 
   /**
@@ -53,14 +53,14 @@
     String changeId = Url.decode(triplet.substring(z + 1));
     return Optional.of(
         new AutoValue_ChangeTriplet(
-            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId)));
+            BranchNameKey.create(Project.nameKey(project), branch), Change.key(changeId)));
   }
 
   public final Project.NameKey project() {
-    return branch().getParentKey();
+    return branch().project();
   }
 
-  public abstract Branch.NameKey branch();
+  public abstract BranchNameKey branch();
 
   public abstract Change.Key id();
 
diff --git a/java/com/google/gerrit/server/change/CommentResource.java b/java/com/google/gerrit/server/change/CommentResource.java
index 1b7cbf8..dbe7a76 100644
--- a/java/com/google/gerrit/server/change/CommentResource.java
+++ b/java/com/google/gerrit/server/change/CommentResource.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.TypeLiteral;
 
 public class CommentResource implements RestResource {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 80b7190..0374a1c 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -30,14 +30,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -238,7 +238,7 @@
   }
 
   private boolean openRepo() {
-    Project.NameKey project = change().getDest().getParentKey();
+    Project.NameKey project = change().getDest().project();
     try {
       repo = repoManager.openRepository(project);
       oi = repo.newObjectInserter();
@@ -265,7 +265,7 @@
     try {
       refs =
           repo.getRefDatabase()
-              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
+              .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new));
     } catch (IOException e) {
       error("error reading refs", e);
       refs = Collections.emptyMap();
@@ -274,12 +274,9 @@
     List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
     for (PatchSet ps : all) {
       // Check revision format.
-      int psNum = ps.getId().get();
-      String refName = ps.getId().toRefName();
-      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
-      if (objId == null) {
-        continue;
-      }
+      int psNum = ps.id().get();
+      String refName = ps.id().toRefName();
+      ObjectId objId = ps.commitId();
       patchSetsBySha.put(objId, ps);
 
       // Check ref existence.
@@ -299,13 +296,13 @@
       RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
       if (psCommit == null) {
         if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
+          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.id()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
         fixPatchSetRef(refProblem, ps);
       }
-      if (ps.getId().equals(change().currentPatchSetId())) {
+      if (ps.id().equals(change().currentPatchSetId())) {
         currPsCommit = psCommit;
       }
     }
@@ -319,7 +316,7 @@
         problem(
             String.format(
                 "Multiple patch sets pointing to %s: %s",
-                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
+                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::number)));
       }
     }
 
@@ -327,7 +324,7 @@
   }
 
   private void checkMerged() {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     Ref dest;
     try {
       dest = repo.getRefDatabase().exactRef(refName);
@@ -351,15 +348,15 @@
       try {
         merged = rw.isMergedInto(currPsCommit, tip);
       } catch (IOException e) {
-        problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
+        problem("Error checking whether patch set " + currPs.id().get() + " is merged");
         return;
       }
-      checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
+      checkMergedBitMatchesStatus(currPs.id(), currPsCommit, merged);
     }
   }
 
   private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     return problem(
         formatProblemMessage(
             "Patch set %d (%s) is merged into destination ref %s (%s), but change"
@@ -368,7 +365,7 @@
   }
 
   private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     if (merged && !change().isMerged()) {
       ProblemInfo p = wrongChangeStatus(psId, commit);
       if (fix != null) {
@@ -379,7 +376,7 @@
           formatProblemMessage(
               "Patch set %d (%s) is not merged into"
                   + " destination ref %s (%s), but change status is %s",
-              currPs.getId().get(), commit.name(), refName, tip.name()));
+              currPs.id().get(), commit.name(), refName, tip.name()));
     }
   }
 
@@ -395,7 +392,11 @@
   }
 
   private void checkExpectMergedAs() {
-    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
+    if (!ObjectId.isId(fix.expectMergedAs)) {
+      problem("Invalid revision on expected merged commit: " + fix.expectMergedAs);
+      return;
+    }
+    ObjectId objId = ObjectId.fromString(fix.expectMergedAs);
     RevCommit commit = parseCommit(objId, "expected merged commit");
     if (commit == null) {
       return;
@@ -406,7 +407,7 @@
         problem(
             String.format(
                 "Expected merged commit %s is not merged into destination ref %s (%s)",
-                commit.name(), change().getDest().get(), tip.name()));
+                commit.name(), change().getDest().branch(), tip.name()));
         return;
       }
 
@@ -420,8 +421,7 @@
           continue;
         }
         try {
-          Change c =
-              notesFactory.createChecked(change().getProject(), psId.getParentKey()).getChange();
+          Change c = notesFactory.createChecked(change().getProject(), psId.changeId()).getChange();
           if (!c.getDest().equals(change().getDest())) {
             continue;
           }
@@ -601,9 +601,9 @@
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
     try {
-      RefUpdate ru = repo.updateRef(ps.getId().toRefName());
+      RefUpdate ru = repo.updateRef(ps.id().toRefName());
       ru.setForceUpdate(true);
-      ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
+      ru.setNewObjectId(ps.commitId());
       ru.setRefLogIdent(newRefLogIdent());
       ru.setRefLogMessage("Repair patch set ref", true);
       RefUpdate.Result result = ru.update();
@@ -630,7 +630,7 @@
       }
     } catch (IOException e) {
       String msg = "Error fixing patch set ref";
-      logger.atWarning().withCause(e).log("%s %s", msg, ps.getId().toRefName());
+      logger.atWarning().withCause(e).log("%s %s", msg, ps.id().toRefName());
       p.status = Status.FIX_FAILED;
       p.outcome = msg;
     }
@@ -640,7 +640,7 @@
     try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
-        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
+        checkArgument(op.psId.changeId().equals(notes.getChangeId()));
         bu.addOp(notes.getChangeId(), op);
       }
       bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
@@ -652,7 +652,7 @@
       }
     } catch (UpdateException | RestApiException e) {
       String msg = "Error deleting patch set";
-      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.getParentKey());
+      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.changeId());
       for (DeletePatchSetFromDbOp op : ops) {
         // Overwrite existing statuses that were set before the transaction was
         // rolled back.
@@ -714,8 +714,8 @@
       // and whether they are seen by this op; we are already given the full set
       // of patch sets that will eventually be deleted in this update.
       for (PatchSet ps : psUtil.byChange(ctx.getNotes())) {
-        if (!toDelete.contains(ps.getId())) {
-          all.add(ps.getId());
+        if (!toDelete.contains(ps.id())) {
+          all.add(ps.id());
         }
       }
       if (all.isEmpty()) {
@@ -734,15 +734,6 @@
     return serverIdent.get();
   }
 
-  private ObjectId parseObjectId(String objIdStr, String desc) {
-    try {
-      return ObjectId.fromString(objIdStr);
-    } catch (IllegalArgumentException e) {
-      problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
-      return null;
-    }
-  }
-
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index ec20e87..14298d5 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.flogger.LazyArgs.lazy;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -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);
   }
@@ -71,8 +76,19 @@
     ensureDeletable(ctx, id, patchSets);
     // Cleaning up is only possible as long as the change and its elements are
     // still part of the database.
-    cleanUpReferences(ctx, id);
+    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;
@@ -87,37 +103,50 @@
       if (isPatchSetMerged(ctx, patchSet)) {
         throw new ResourceConflictException(
             String.format(
-                "Cannot delete change %s: patch set %s is already merged",
-                id, patchSet.getPatchSetId()));
+                "Cannot delete change %s: patch set %s is already merged", id, patchSet.number()));
       }
     }
   }
 
   private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().branch());
     if (!destId.isPresent()) {
       return false;
     }
 
     RevWalk revWalk = ctx.getRevWalk();
-    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
+    return revWalk.isMergedInto(
+        revWalk.parseCommit(patchSet.commitId()), revWalk.parseCommit(destId.get()));
   }
 
-  private void cleanUpReferences(ChangeContext ctx, Change.Id id) throws NoSuchChangeException {
+  private void cleanUpReferences(Change.Id id) throws IOException {
     accountPatchReviewStore.run(s -> s.clearReviewed(id));
 
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
+    // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
+    starredChangesUtil.unstarAllForChangeDeletion(id);
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
-      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
+    String changeRefPrefix = RefNames.changeRefPrefix(id);
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(changeRefPrefix).entrySet()) {
+      removeRef(ctx, e, changeRefPrefix);
     }
+    removeUserEdits(ctx);
+  }
+
+  private void removeUserEdits(RepoContext ctx) throws IOException {
+    String prefix = RefNames.REFS_USERS;
+    String editRef = String.format("/edit-%s/", id);
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      if (e.getKey().contains(editRef)) {
+        removeRef(ctx, e, prefix);
+      }
+    }
+  }
+
+  private void removeRef(RepoContext ctx, Map.Entry<String, ObjectId> entry, String prefix)
+      throws IOException {
+    ctx.addRefUpdate(entry.getValue(), ObjectId.zeroId(), prefix + entry.getKey());
   }
 }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 4b5572b..3bc9324 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -55,7 +55,7 @@
     String msg = "Removed reviewer " + reviewer;
     changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
+            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
             ctx.getAccountId(),
             ctx.getWhen(),
             psId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 29458a8..c4de02c 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -18,17 +18,17 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -108,7 +108,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
-    Account.Id reviewerId = reviewer.getAccount().getId();
+    Account.Id reviewerId = reviewer.account().id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
 
@@ -125,7 +125,7 @@
     }
 
     StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.getAccount().getFullName());
+    msg.append("Removed reviewer " + reviewer.account().fullName());
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     List<PatchSetApproval> del = new ArrayList<>();
@@ -134,14 +134,14 @@
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
       del.add(a);
-      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-        oldApprovals.put(a.getLabel(), a.getValue());
+      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+        oldApprovals.put(a.label(), a.value());
         removedVotesMsg
             .append("* ")
-            .append(a.getLabel())
-            .append(formatLabelValue(a.getValue()))
+            .append(a.label())
+            .append(formatLabelValue(a.value()))
             .append(" by ")
-            .append(userFactory.create(a.getAccountId()).getNameEmail())
+            .append(userFactory.create(a.accountId()).getNameEmail())
             .append("\n");
         votesRemoved = true;
       }
@@ -152,7 +152,7 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    ChangeUpdate update = ctx.getUpdate(currPs.id());
     update.removeReviewer(reviewerId);
 
     changeMessage =
@@ -195,7 +195,7 @@
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
     approvals = approvalsUtil.byChange(ctx.getNotes()).values();
-    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
   private String formatLabelValue(short value) {
@@ -206,16 +206,19 @@
   }
 
   private void emailReviewers(
-      NameKey projectName, Change change, ChangeMessage changeMessage, NotifyResolver.Result notify)
+      Project.NameKey projectName,
+      Change change,
+      ChangeMessage changeMessage,
+      NotifyResolver.Result notify)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.getAccount().getId())) {
+    if (userId.equals(reviewer.account().id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
     DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
     cm.setFrom(userId);
-    cm.addReviewers(Collections.singleton(reviewer.getAccount().getId()));
+    cm.addReviewers(Collections.singleton(reviewer.account().id()));
     cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
     cm.setNotify(notify);
     cm.send();
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index ef31725..3d3e8f9 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 8353501..f7e45e7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -18,9 +18,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -129,7 +129,7 @@
       cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.getId());
+      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index a806f94..5c7946c 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -21,10 +21,10 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript.FileMode;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.project.ProjectState;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index 56cc8df..a823975 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -44,27 +43,20 @@
 
   public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.getRevision(), null);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
-    ObjectId objectId = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, objectId, base);
+    return toFileInfoMap(change, patchSet.commitId(), null);
   }
 
   public Map<String, FileInfo> toFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
-    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
+    ObjectId a = base != null ? base.commitId() : null;
     return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
       throws PatchListNotAvailableException {
-    ObjectId b = ObjectId.fromString(revision.get());
     return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
+        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
   }
 
   private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index bd7557f..5402338 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.inject.TypeLiteral;
 
 public class FileResource implements RestResource {
@@ -29,7 +29,7 @@
 
   public FileResource(RevisionResource rev, String name) {
     this.rev = rev;
-    this.key = new Patch.Key(rev.getPatchSet().getId(), name);
+    this.key = Patch.key(rev.getPatchSet().id(), name);
   }
 
   public Patch.Key getPatchKey() {
diff --git a/java/com/google/gerrit/server/change/FixResource.java b/java/com/google/gerrit/server/change/FixResource.java
index 08e2785..b6b5894 100644
--- a/java/com/google/gerrit/server/change/FixResource.java
+++ b/java/com/google/gerrit/server/change/FixResource.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.FixReplacement;
 import com.google.inject.TypeLiteral;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index 3ac6959..3c66c2c 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -16,12 +16,12 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 1ec1717..1ef3aee 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
@@ -88,24 +88,23 @@
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
     LabelTypes labelTypes = projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes);
     for (PatchSetApproval psa : approvals) {
-      Change.Id changeId = psa.getKey().getParentKey().getParentKey();
+      Change.Id changeId = psa.key().patchSetId().changeId();
       checkArgument(
           changeId.equals(notes.getChangeId()),
           "Approval %s does not match change %s",
-          psa.getKey(),
+          psa.key(),
           notes.getChange().getKey());
       if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.getLabelId());
+      LabelType label = labelTypes.byLabel(psa.labelId());
       if (label == null) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = copy(psa);
-      applyTypeFloor(label, copy);
-      if (copy.getValue() != psa.getValue()) {
+      PatchSetApproval copy = applyTypeFloor(label, psa);
+      if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
         unchanged.add(psa);
@@ -114,18 +113,16 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  private PatchSetApproval copy(PatchSetApproval src) {
-    return new PatchSetApproval(src.getPatchSetId(), src);
-  }
-
-  private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
+  private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
+    PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
-    if (atMin != null && a.getValue() < atMin.getValue()) {
-      a.setValue(atMin.getValue());
+    if (atMin != null && a.value() < atMin.getValue()) {
+      b.value(atMin.getValue());
     }
     LabelValue atMax = lt.getMax();
-    if (atMax != null && a.getValue() > atMax.getValue()) {
-      a.setValue(atMax.getValue());
+    if (atMax != null && a.value() > atMax.getValue()) {
+      b.value(atMax.getValue());
     }
+    return b.build();
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 6fde5a5..c6f4969 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,12 +36,12 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountLoader;
@@ -206,8 +206,8 @@
       if (standard) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
           if (type.matches(psa)) {
-            short val = psa.getValue();
-            Account.Id accountId = psa.getAccountId();
+            short val = psa.value();
+            Account.Id accountId = psa.accountId();
             setLabelScores(accountLoader, type, e.getValue(), val, accountId);
           }
         }
@@ -260,7 +260,7 @@
             accountId,
             null,
             null)) {
-      result.put(psa.getLabel(), psa.getValue());
+      result.put(psa.label(), psa.value());
     }
     return result;
   }
@@ -279,7 +279,7 @@
       // we aren't including 0 votes for all users below, so we can just look at
       // the latest patch set (in the next loop).
       for (PatchSetApproval psa : cd.approvals().values()) {
-        allUsers.add(psa.getAccountId());
+        allUsers.add(psa.accountId());
       }
     }
 
@@ -287,13 +287,13 @@
     SetMultimap<Account.Id, PatchSetApproval> current =
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
-      allUsers.add(a.getAccountId());
-      LabelType type = labelTypes.byLabel(a.getLabelId());
+      allUsers.add(a.accountId());
+      LabelType type = labelTypes.byLabel(a.labelId());
       if (type != null) {
         labelNames.add(type.getName());
         // Not worth the effort to distinguish between votable/non-votable for 0
         // values on closed changes, since they can't vote anyway.
-        current.put(a.getAccountId(), a);
+        current.put(a.accountId(), a);
       }
     }
 
@@ -335,19 +335,19 @@
         }
       }
       for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.getLabelId());
+        LabelType type = labelTypes.byLabel(psa.labelId());
         if (type == null) {
           continue;
         }
 
-        short val = psa.getValue();
+        short val = psa.value();
         ApprovalInfo info = byLabel.get(type.getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
           info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
-          info.date = psa.getGranted();
-          info.tag = psa.getTag();
-          if (psa.isPostSubmit()) {
+          info.date = psa.granted();
+          info.tag = psa.tag().orElse(null);
+          if (psa.postSubmit()) {
             info.postSubmit = true;
           }
         }
@@ -441,13 +441,13 @@
     Set<Account.Id> allUsers = new HashSet<>();
     allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
     for (PatchSetApproval psa : cd.approvals().values()) {
-      allUsers.add(psa.getAccountId());
+      allUsers.add(psa.accountId());
     }
 
     Table<Account.Id, String, PatchSetApproval> current =
         HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      current.put(psa.getAccountId(), psa.getLabel(), psa);
+      current.put(psa.accountId(), psa.label(), psa);
     }
 
     LabelTypes labelTypes = cd.getLabelTypes();
@@ -467,16 +467,16 @@
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
         if (psa != null) {
-          value = Integer.valueOf(psa.getValue());
+          value = Integer.valueOf(psa.value());
           if (value == 0) {
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
             value = perm.test(new LabelPermission(lt)) ? 0 : null;
           }
-          tag = psa.getTag();
-          date = psa.getGranted();
-          if (psa.isPostSubmit()) {
+          tag = psa.tag().orElse(null);
+          date = psa.granted();
+          if (psa.postSubmit()) {
             logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
           }
         } else {
diff --git a/java/com/google/gerrit/server/change/MergeabilityCache.java b/java/com/google/gerrit/server/change/MergeabilityCache.java
index 3a7f3ab..b432bc9 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -29,7 +29,7 @@
         Ref intoRef,
         SubmitType submitType,
         String mergeStrategy,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         Repository repo) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
@@ -46,7 +46,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo);
 
   Boolean getIfPresent(ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy);
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index d408519..44af1e4 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -24,9 +24,9 @@
 import com.google.common.cache.Weigher;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
@@ -191,7 +191,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo) {
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
diff --git a/java/com/google/gerrit/server/change/NotifyResolver.java b/java/com/google/gerrit/server/change/NotifyResolver.java
index 5b15684..27951ca 100644
--- a/java/com/google/gerrit/server/change/NotifyResolver.java
+++ b/java/com/google/gerrit/server/change/NotifyResolver.java
@@ -21,12 +21,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -99,7 +99,7 @@
     List<String> problems = new ArrayList<>(inputs.size());
     for (String nameOrEmail : inputs) {
       try {
-        r.add(accountResolver.resolve(nameOrEmail).asUnique().getAccount().getId());
+        r.add(accountResolver.resolve(nameOrEmail).asUnique().account().id());
       } catch (UnprocessableEntityException e) {
         problems.add(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d3649f6..71c54b1 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -20,13 +20,13 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -208,7 +208,7 @@
     if (newGroups.isEmpty()) {
       PatchSet prevPs = psUtil.current(ctx.getNotes());
       if (prevPs != null) {
-        newGroups = prevPs.getGroups();
+        newGroups = prevPs.groups();
       }
     }
     patchSet =
@@ -222,7 +222,7 @@
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
               ctx.getWhen(),
               message,
@@ -288,7 +288,7 @@
                 commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
             projectCache.checkedGet(origNotes.getProjectName()).getProject(),
-            origNotes.getChange().getDest().get(),
+            origNotes.getChange().getDest().branch(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index acb3dd7..cb632dc 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -54,8 +54,6 @@
     }
 
     return pureRevertCache.isPureRevert(
-        notes.getProjectName(),
-        ObjectId.fromString(notes.getCurrentPatchSet().getRevision().get()),
-        claimedOriginalObjectId);
+        notes.getProjectName(), notes.getCurrentPatchSet().commitId(), claimedOriginalObjectId);
   }
 }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index fccda7c..4723af8 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -16,11 +16,10 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -151,10 +150,8 @@
           NoSuchChangeException, PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
-    RevId oldRev = originalPatchSet.getRevision();
-
     RevWalk rw = ctx.getRevWalk();
-    RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
+    RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
     RevCommit baseCommit = rw.parseCommit(baseCommitId);
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
@@ -164,7 +161,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.id());
     } else {
       newCommitMessage = original.getFullMessage();
     }
@@ -178,9 +175,7 @@
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
-            ctx.getRepoView()
-                .getRefs(originalPatchSet.getId().getParentKey().toRefPrefix())
-                .keySet(),
+            ctx.getRepoView().getRefs(originalPatchSet.id().changeId().toRefPrefix()).keySet(),
             notes.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
@@ -195,14 +190,14 @@
           "Patch Set "
               + rebasedPatchSetId.get()
               + ": Patch Set "
-              + originalPatchSet.getId().get()
+              + originalPatchSet.id().get()
               + " was rebased");
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
       if (!base.notes().getChange().isMerged()) {
         // Add to end of relation chain for open base change.
-        patchSetInserter.setGroups(base.patchSet().getGroups());
+        patchSetInserter.setGroups(base.patchSet().groups());
       } else {
         // If the base is merged, start a new relation chain.
         patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index a4cf5ba..2d36df2 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,14 +17,14 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -56,7 +56,7 @@
     this.psUtil = psUtil;
   }
 
-  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
+  public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
       findBaseRevision(patchSet, dest, git, rw);
       return true;
@@ -64,7 +64,7 @@
       return false;
     } catch (StorageException | IOException e) {
       logger.atWarning().withCause(e).log(
-          "Error checking if patch set %s on %s can be rebased", patchSet.getId(), dest);
+          "Error checking if patch set %s on %s can be rebased", patchSet.id(), dest);
       return false;
     }
   }
@@ -87,18 +87,18 @@
     // Try parsing the base as a ref string.
     PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
     if (basePatchSetId != null) {
-      Change.Id baseChangeId = basePatchSetId.getParentKey();
+      Change.Id baseChangeId = basePatchSetId.changeId();
       ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
       if (baseNotes != null) {
         return Base.create(
-            notesFor(rsrc, basePatchSetId.getParentKey()), psUtil.get(baseNotes, basePatchSetId));
+            notesFor(rsrc, basePatchSetId.changeId()), psUtil.get(baseNotes, basePatchSetId));
       }
     }
 
     // Try parsing base as a change number (assume current patch set).
     Integer baseChangeId = Ints.tryParse(base);
     if (baseChangeId != null) {
-      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
+      ChangeNotes baseNotes = notesFor(rsrc, Change.id(baseChangeId));
       if (baseNotes != null) {
         return Base.create(baseNotes, psUtil.current(baseNotes));
       }
@@ -108,10 +108,10 @@
     Base ret = null;
     for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
       for (PatchSet ps : cd.patchSets()) {
-        if (!ps.getRevision().matches(base)) {
+        if (!ObjectIds.matchesAbbreviation(ps.commitId(), base)) {
           continue;
         }
-        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
+        if (ret == null || ret.patchSet().id().get() < ps.id().get()) {
           ret = Base.create(cd.notes(), ps);
         }
       }
@@ -141,10 +141,10 @@
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
       throws RestApiException, IOException {
-    String baseRev = null;
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+    ObjectId baseId = null;
+    RevCommit commit = rw.parseCommit(patchSet.commitId());
 
     if (commit.getParentCount() > 1) {
       throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
@@ -153,12 +153,12 @@
           "Cannot rebase a change without any parents (is this the initial commit?).");
     }
 
-    RevId parentRev = new RevId(commit.getParent(0).name());
+    ObjectId parentId = commit.getParent(0);
 
     CHANGES:
-    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
+    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentId.name())) {
       for (PatchSet depPatchSet : cd.patchSets()) {
-        if (!depPatchSet.getRevision().equals(parentRev)) {
+        if (!depPatchSet.commitId().equals(parentId)) {
           continue;
         }
         Change depChange = cd.change();
@@ -168,29 +168,29 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
+          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
-          baseRev = cd.currentPatchSet().getRevision().get();
+          baseId = cd.currentPatchSet().commitId();
         }
         break CHANGES;
       }
     }
 
-    if (baseRev == null) {
+    if (baseId == null) {
       // We are dependent on a merged PatchSet or have no PatchSet
       // dependencies at all.
-      Ref destRef = git.getRefDatabase().exactRef(destBranch.get());
+      Ref destRef = git.getRefDatabase().exactRef(destBranch.branch());
       if (destRef == null) {
         throw new UnprocessableEntityException(
-            "The destination branch does not exist: " + destBranch.get());
+            "The destination branch does not exist: " + destBranch.branch());
       }
-      baseRev = destRef.getObjectId().getName();
-      if (baseRev.equals(parentRev.get())) {
+      baseId = destRef.getObjectId();
+      if (baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
-    return ObjectId.fromString(baseRev);
+    return baseId;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index a6ad559..ba6ba21 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -31,6 +31,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -41,13 +48,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -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,
@@ -340,11 +347,11 @@
 
     for (Account member : members) {
       if (isValidReviewer(notes.getChange().getDest(), member)) {
-        reviewers.add(member.getId());
+        reviewers.add(member.id());
       }
     }
 
-    return new ReviewerAddition(input, notes, user, reviewers, null, true);
+    return new ReviewerAddition(input, notes, user, reviewers, null, true, true);
   }
 
   @Nullable
@@ -366,16 +373,16 @@
           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(Branch.NameKey branch, Account member)
+  private boolean isValidReviewer(BranchNameKey branch, Account member)
       throws PermissionBackendException {
     try {
       // Check ref permission instead of change permission, since change permissions take into
       // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
       // see private changes.
-      permissionBackend.absentUser(member.getId()).ref(branch).check(RefPermission.READ);
+      permissionBackend.absentUser(member.id()).ref(branch).check(RefPermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
@@ -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;
     }
 
@@ -469,8 +477,8 @@
           // New reviewers have value 0, don't bother normalizing.
           result.reviewers.add(
               json.format(
-                  new ReviewerInfo(psa.getAccountId().get()),
-                  psa.getAccountId(),
+                  new ReviewerInfo(psa.accountId().get()),
+                  psa.accountId(),
                   cd,
                   ImmutableList.of(psa)));
         }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 2742bb9..6686ed8 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -111,9 +111,9 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      LabelType at = labelTypes.byLabel(ca.labelId());
       if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.getValue()));
+        out.approvals.put(at.getName(), formatValue(ca.value()));
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 52f3585..df0a03f 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -17,11 +17,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/change/ReviewerSuggestion.java b/java/com/google/gerrit/server/change/ReviewerSuggestion.java
index 198a5fd..1b2a008 100644
--- a/java/com/google/gerrit/server/change/ReviewerSuggestion.java
+++ b/java/com/google/gerrit/server/change/ReviewerSuggestion.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import java.util.Set;
 
 /**
@@ -35,9 +35,8 @@
    * @param changeId The changeId that the suggestion is for. Can be {@code null}.
    * @param query The query as typed by the user. Can be {@code null}.
    * @param candidates A set of candidates for the ranking. Can be empty.
-   * @return Set of {@link SuggestedReviewer}s. The {@link
-   *     com.google.gerrit.reviewdb.client.Account.Id}s listed here don't have to be included in
-   *     {@code candidates}.
+   * @return Set of {@link SuggestedReviewer}s. The {@link com.google.gerrit.entities.Account.Id}s
+   *     listed here don't have to be included in {@code candidates}.
    */
   Set<SuggestedReviewer> suggestReviewers(
       Project.NameKey project,
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index aa733cd..fbd14c4 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -28,10 +28,15 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -44,10 +49,6 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -71,7 +72,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -182,7 +182,7 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -192,7 +192,7 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -216,7 +216,7 @@
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.getId();
+        PatchSet.Id id = in.id();
         boolean want;
         if (has(ALL_REVISIONS)) {
           want = true;
@@ -227,7 +227,7 @@
         }
         if (want) {
           res.put(
-              in.getRevision().get(),
+              in.commitId().name(),
               toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
         }
       }
@@ -251,7 +251,7 @@
 
       String projectName = cd.project().get();
       String url = scheme.getUrl(projectName);
-      String refName = in.getRefName();
+      String refName = in.refName();
       FetchInfo fetchInfo = new FetchInfo(url, refName);
       r.put(schemeName, fetchInfo);
 
@@ -275,14 +275,14 @@
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
-    out.isCurrent = in.getId().equals(c.currentPatchSetId());
-    out._number = in.getId().get();
-    out.ref = in.getRefName();
-    out.created = in.getCreatedOn();
-    out.uploader = accountLoader.get(in.getUploader());
+    out.isCurrent = in.id().equals(c.currentPatchSetId());
+    out._number = in.id().get();
+    out.ref = in.refName();
+    out.created = in.createdOn();
+    out.uploader = accountLoader.get(in.uploader());
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
-    out.description = in.getDescription();
+    out.description = in.description().orElse(null);
 
     boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
@@ -290,14 +290,14 @@
       checkState(rw != null);
       checkState(repo != null);
       Project.NameKey project = c.getProject();
-      String rev = in.getRevision().get();
+      String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
       if (setCommit) {
         out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().get());
+        Ref ref = repo.exactRef(cd.change().getDest().branch());
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
@@ -306,7 +306,7 @@
         out.commitWithFooters =
             mergeUtilFactory
                 .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.id());
       }
     }
 
@@ -324,10 +324,10 @@
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
-      if (in.getPushCertificate() != null) {
+      if (in.pushCertificate().isPresent()) {
         out.pushCertificate =
             gpgApi.checkPushCertificate(
-                in.getPushCertificate(), userFactory.create(in.getUploader()));
+                in.pushCertificate().get(), userFactory.create(in.uploader()));
       } else {
         out.pushCertificate = new PushCertificateInfo();
       }
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index caafe24..30fa593 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -16,15 +16,18 @@
 
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.TypeLiteral;
@@ -89,9 +92,18 @@
 
   @Override
   public String getETag() {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    prepareETag(h, getUser());
-    return h.hash().toString();
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Compute revision ETag",
+            Metadata.builder()
+                .changeId(change.getId().get())
+                .patchSetId(ps.number())
+                .projectName(change.getProject().get())
+                .build())) {
+      Hasher h = Hashing.murmur3_128().newHasher();
+      prepareETag(h, getUser());
+      return h.hash().toString();
+    }
   }
 
   public void prepareETag(Hasher h, CurrentUser user) {
@@ -114,7 +126,7 @@
 
   @Override
   public String toString() {
-    String s = ps.getId().toString();
+    String s = ps.id().toString();
     if (edit.isPresent()) {
       s = "edit:" + s;
     }
@@ -122,6 +134,6 @@
   }
 
   public boolean isCurrent() {
-    return ps.getId().equals(getChange().currentPatchSetId());
+    return ps.id().equals(getChange().currentPatchSetId());
   }
 }
diff --git a/java/com/google/gerrit/server/change/RobotCommentResource.java b/java/com/google/gerrit/server/change/RobotCommentResource.java
index c4fab58..b12727d 100644
--- a/java/com/google/gerrit/server/change/RobotCommentResource.java
+++ b/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.inject.TypeLiteral;
 
 public class RobotCommentResource implements RestResource {
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 8d350c3..9848150 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -17,10 +17,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index abc4eee..712e1f3 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -21,12 +21,12 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.change.HashtagsUtil.InvalidHashtagException;
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 1600fd5..28d178d 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -16,11 +16,11 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
diff --git a/java/com/google/gerrit/server/change/SuggestedReviewer.java b/java/com/google/gerrit/server/change/SuggestedReviewer.java
index 353bf3b..1b30199 100644
--- a/java/com/google/gerrit/server/change/SuggestedReviewer.java
+++ b/java/com/google/gerrit/server/change/SuggestedReviewer.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 
 public class SuggestedReviewer {
 
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 5945a0c..816a904 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -24,9 +24,9 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -41,7 +41,6 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
@@ -223,27 +222,26 @@
     for (ChangeData cd : in) {
       PatchSet maxPs = null;
       for (PatchSet ps : cd.patchSets()) {
-        if (shouldInclude(ps) && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
+        if (shouldInclude(ps) && (maxPs == null || ps.id().get() > maxPs.id().get())) {
           maxPs = ps;
         }
       }
       if (maxPs == null) {
         continue; // No patch sets matched.
       }
-      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
       try {
-        RevCommit c = rw.parseCommit(id);
+        RevCommit c = rw.parseCommit(maxPs.commitId());
         byCommit.put(c, PatchSetData.create(cd, maxPs, c));
       } catch (MissingObjectException | IncorrectObjectTypeException e) {
         logger.atWarning().withCause(e).log(
-            "missing commit %s for patch set %s", id.name(), maxPs.getId());
+            "missing commit %s for patch set %s", maxPs.commitId().name(), maxPs.id());
       }
     }
     return byCommit;
   }
 
   private boolean shouldInclude(PatchSet ps) {
-    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
+    return includePatchSets.isEmpty() || includePatchSets.contains(ps.id());
   }
 
   private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index f3f1a29..78edadab 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -17,10 +17,10 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
diff --git a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java b/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
new file mode 100644
index 0000000..344b9b3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change.testing;
+
+import com.google.gerrit.server.change.ChangeETagComputation;
+
+public class TestChangeETagComputation {
+
+  public static ChangeETagComputation withETag(String etag) {
+    return (p, id) -> etag;
+  }
+
+  public static ChangeETagComputation withException(RuntimeException e) {
+    return (p, id) -> {
+      throw e;
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
index 7719e38..6d5525c 100644
--- a/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 /** Special name of the project that all projects derive from. */
 public class AllProjectsName extends Project.NameKey {
diff --git a/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
index 22d29a4..aa92db8 100644
--- a/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/java/com/google/gerrit/server/config/AllUsersName.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 /** Special name of the project in which meta data for all users is stored. */
 public class AllUsersName extends Project.NameKey {
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index e9d5e5e..6dea07d 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
-import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 5dad6a8..e17fb02 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
@@ -76,7 +77,10 @@
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDeactivator;
@@ -98,6 +102,7 @@
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -135,6 +140,7 @@
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.db.GroupDbModule;
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.mail.AutoReplyMailFilter;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
@@ -142,7 +148,8 @@
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
+import com.google.gerrit.server.mail.send.MailSoySauceProvider;
+import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mail.send.MailTemplates;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
@@ -190,7 +197,7 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
@@ -281,7 +288,7 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(SoyTofu.class).annotatedWith(MailTemplates.class).toProvider(MailSoyTofuProvider.class);
+    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(EnableReverseDnsLookup.class)
@@ -341,6 +348,7 @@
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
@@ -380,6 +388,12 @@
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
+    DynamicSet.setOf(binder(), PerformanceLogger.class);
+    DynamicSet.setOf(binder(), RequestListener.class);
+    DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
+    DynamicSet.setOf(binder(), ChangeETagComputation.class);
+    DynamicSet.setOf(binder(), ExceptionHook.class);
+    DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/java/com/google/gerrit/server/config/GroupSetProvider.java b/java/com/google/gerrit/server/config/GroupSetProvider.java
index 2255a67..7f487e1 100644
--- a/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 2164a86..9e45701 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.NoSuchProjectException;
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index 92ae10a..fcfa5e9 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,14 +17,14 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -316,7 +316,7 @@
 
     @Override
     public void onGitReferenceUpdated(Event event) {
-      Project.NameKey p = new Project.NameKey(event.getProjectName());
+      Project.NameKey p = Project.nameKey(event.getProjectName());
       if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
         return;
       }
diff --git a/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index a2e0356..c2538ac 100644
--- a/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index d8c8468..f722321 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -19,8 +19,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 47b6336..ee95c6f 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -54,6 +54,8 @@
   public final Path secure_config;
   public final Path notedb_config;
 
+  public final Path jgit_config;
+
   public final Path ssl_keystore;
   public final Path ssh_key;
   public final Path ssh_rsa;
@@ -99,6 +101,8 @@
     secure_config = etc_dir.resolve("secure.config");
     notedb_config = etc_dir.resolve("notedb.config");
 
+    jgit_config = etc_dir.resolve("jgit.config");
+
     ssl_keystore = etc_dir.resolve("keystore");
     ssh_key = etc_dir.resolve("ssh_host_key");
     ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index eb1e0b2..6b2510e 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import java.util.Optional;
 
 /**
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index fde5922..a6da2b9 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gson.annotations.SerializedName;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/server/data/PatchAttribute.java b/java/com/google/gerrit/server/data/PatchAttribute.java
index 22f18af..2428c54 100644
--- a/java/com/google/gerrit/server/data/PatchAttribute.java
+++ b/java/com/google/gerrit/server/data/PatchAttribute.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.data;
 
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.ChangeType;
 
 public class PatchAttribute {
   public String file;
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 5d3d1f2..59ae6f8 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -80,8 +80,7 @@
     }
     Query query = parser.parse(q);
     try {
-      // TODO(fishywang): Currently as we don't have much documentation, we just use MAX_VALUE here
-      // and skipped paging. Maybe add paging later.
+      // We don't have much documentation, so we just use MAX_VALUE here and skip paging.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
       ScoreDoc[] hits = results.scoreDocs;
       long totalHits = results.totalHits;
diff --git a/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
index 11dc380..c652289 100644
--- a/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -16,8 +16,8 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
diff --git a/java/com/google/gerrit/server/edit/ChangeEditJson.java b/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 55e0aef..25dcae0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -51,8 +51,8 @@
   public EditInfo toEditInfo(ChangeEdit edit, boolean downloadCommands) {
     EditInfo out = new EditInfo();
     out.commit = fillCommit(edit.getEditCommit());
-    out.baseRevision = edit.getBasePatchSet().getRevision().get();
-    out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
+    out.baseRevision = edit.getBasePatchSet().commitId().name();
+    out.basePatchSetNumber = edit.getBasePatchSet().number();
     out.ref = edit.getRefName();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 246a53f..ee93fbf 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.edit;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -125,7 +125,7 @@
     }
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
-    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
+    ObjectId patchSetCommitId = currentPatchSet.commitId();
     createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
@@ -158,7 +158,7 @@
       throw new InvalidChangeOperationException(
           String.format(
               "Change edit for change %s is already based on latest patch set %s",
-              notes.getChangeId(), currentPatchSet.getId()));
+              notes.getChangeId(), currentPatchSet.id()));
     }
 
     rebase(repository, changeEdit, currentPatchSet);
@@ -375,7 +375,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;
       }
@@ -425,10 +425,10 @@
             String.format(
                 "Only the patch set %s on which the existing change edit is based may be modified "
                     + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+                changeEdit.getBasePatchSet().id(), patchSet.id()));
       }
     } else {
-      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id patchSetId = patchSet.id();
       PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
       if (!patchSetId.equals(currentPatchSetId)) {
         throw new InvalidChangeOperationException(
@@ -455,12 +455,12 @@
 
   private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
     PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
-    return editBasePatchSet.getId().equals(patchSet.getId());
+    return editBasePatchSet.id().equals(patchSet.id());
   }
 
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
-    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    ObjectId patchSetCommitId = patchSet.commitId();
     return lookupCommit(repository, patchSetCommitId);
   }
 
@@ -483,7 +483,7 @@
       throw new BadRequestException(e.getMessage());
     }
 
-    if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
+    if (ObjectId.isEqual(newTreeId, baseCommit.getTree())) {
       throw new InvalidChangeOperationException("no changes were made");
     }
     return newTreeId;
@@ -492,7 +492,7 @@
   private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
-    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
+    ObjectId basePatchSetCommitId = basePatchSet.commitId();
     ObjectId editCommitId = changeEdit.getEditCommit();
 
     ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
@@ -531,10 +531,6 @@
     return user.newCommitterIdent(commitTimestamp, tz);
   }
 
-  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
-    return ObjectId.fromString(patchSet.getRevision().get());
-  }
-
   private ChangeEdit createEdit(
       Repository repository,
       ChangeNotes notes,
@@ -553,7 +549,7 @@
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
     IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
+    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.id());
   }
 
   private ChangeEdit updateEdit(
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index ef1d880..ea34b76 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -16,14 +16,14 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -122,7 +122,7 @@
       String[] refNames = new String[n];
       for (int i = n; i > 0; i--) {
         refNames[i - 1] =
-            RefNames.refsEdit(u.getAccountId(), change.getId(), new PatchSet.Id(change.getId(), i));
+            RefNames.refsEdit(u.getAccountId(), change.getId(), PatchSet.id(change.getId(), i));
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
       if (ref == null) {
@@ -162,7 +162,7 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
-      if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
+      if (!basePatchSet.id().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
@@ -177,17 +177,14 @@
           new StringBuilder("Patch Set ").append(inserter.getPatchSetId().get()).append(": ");
 
       // Previously checked that the base patch set is the current patch set.
-      ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
+      ObjectId prior = basePatchSet.commitId();
       ChangeKind kind =
           changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
         inserter.setDescription("Edit commit message");
       } else {
-        message
-            .append("Published edit on patch set ")
-            .append(basePatchSet.getPatchSetId())
-            .append(".");
+        message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
       try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
@@ -226,7 +223,7 @@
       int pos = ref.getName().lastIndexOf('/');
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
-      return psUtil.get(notes, new PatchSet.Id(notes.getChange().getId(), Integer.parseInt(psId)));
+      return psUtil.get(notes, PatchSet.id(notes.getChange().getId(), Integer.parseInt(psId)));
     } catch (StorageException | NumberFormatException e) {
       throw new IOException(e);
     }
@@ -235,7 +232,7 @@
   private RevCommit squashEdit(
       RevWalk rw, ObjectInserter inserter, RevCommit edit, PatchSet basePatchSet)
       throws IOException, ResourceConflictException {
-    RevCommit parent = rw.parseCommit(ObjectId.fromString(basePatchSet.getRevision().get()));
+    RevCommit parent = rw.parseCommit(basePatchSet.commitId());
     if (parent.getTree().equals(edit.getTree())
         && edit.getFullMessage().equals(parent.getFullMessage())) {
       throw new ResourceConflictException("identical tree and message");
diff --git a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
index 60a0935..490d6d14 100644
--- a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
+++ b/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class AssigneeChangedEvent extends ChangeEvent {
diff --git a/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java b/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
index 32b5b02..066f398 100644
--- a/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeAbandonedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/ChangeDeletedEvent.java b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
index 63142fd..017a839b 100644
--- a/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeDeletedEvent extends ChangeEvent {
diff --git a/java/com/google/gerrit/server/events/ChangeEvent.java b/java/com/google/gerrit/server/events/ChangeEvent.java
index 6029ded..d39099c 100644
--- a/java/com/google/gerrit/server/events/ChangeEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeEvent.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.data.ChangeAttribute;
 
 public abstract class ChangeEvent extends RefEvent {
@@ -29,7 +29,7 @@
   protected ChangeEvent(String type, Change change) {
     super(type);
     this.project = change.getProject();
-    this.refName = RefNames.fullName(change.getDest().get());
+    this.refName = RefNames.fullName(change.getDest().branch());
     this.changeKey = change.getKey();
   }
 
diff --git a/java/com/google/gerrit/server/events/ChangeMergedEvent.java b/java/com/google/gerrit/server/events/ChangeMergedEvent.java
index 3fb2ac8..3b96328 100644
--- a/java/com/google/gerrit/server/events/ChangeMergedEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeMergedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeMergedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/ChangeRestoredEvent.java b/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
index 7c86d70..48100a1 100644
--- a/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeRestoredEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/CommentAddedEvent.java b/java/com/google/gerrit/server/events/CommentAddedEvent.java
index bb1ac4d..dbbebe8 100644
--- a/java/com/google/gerrit/server/events/CommentAddedEvent.java
+++ b/java/com/google/gerrit/server/events/CommentAddedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index c0f9c29..6e43621 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index d5f548f..a6db081 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -47,6 +48,8 @@
     protected void configure() {
       DynamicItem.itemOf(binder(), EventDispatcher.class);
       DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
+
+      bind(Gson.class).annotatedWith(EventGson.class).toProvider(EventGsonProvider.class);
     }
   }
 
@@ -81,7 +84,7 @@
   }
 
   @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event)
+  public void postEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
     fireEvent(branchName, event);
   }
@@ -120,7 +123,7 @@
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
+  protected void fireEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
       CurrentUser user = c.call(UserScopedEventListener::getUser);
@@ -174,9 +177,9 @@
     }
   }
 
-  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
+  protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
       throws PermissionBackendException {
-    ProjectState pe = projectCache.get(branchName.getParentKey());
+    ProjectState pe = projectCache.get(branchName.project());
     if (pe == null || !pe.statePermitsRead()) {
       return false;
     }
@@ -194,13 +197,13 @@
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
       if (PatchSet.isChangeRef(ref)) {
-        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
+        Change.Id cid = PatchSet.Id.fromRef(ref).changeId();
         try {
           Change change = notesFactory.createChecked(refEvent.getProjectNameKey(), cid).getChange();
           return isVisibleTo(change, user);
         } catch (NoSuchChangeException e) {
           logger.atFine().log(
-              "Change %s cannot be found, falling back on ref visibility check", cid.id);
+              "Change %s cannot be found, falling back on ref visibility check", cid.get());
         }
       }
       return isVisibleTo(refEvent.getBranchNameKey(), user);
diff --git a/java/com/google/gerrit/server/events/EventDispatcher.java b/java/com/google/gerrit/server/events/EventDispatcher.java
index e6735f2..a358aee 100644
--- a/java/com/google/gerrit/server/events/EventDispatcher.java
+++ b/java/com/google/gerrit/server/events/EventDispatcher.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 
 /** Interface for posting (dispatching) Events */
@@ -37,7 +37,7 @@
    * @param event The event to post
    * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
+  void postEvent(BranchNameKey branchName, RefEvent event) throws PermissionBackendException;
 
   /**
    * Post a stream event that is related to a project.
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index efd9bb9..3f22d7f 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -24,18 +24,18 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
@@ -68,7 +68,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -127,7 +126,7 @@
   public ChangeAttribute asChangeAttribute(Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
+    a.branch = change.getDest().shortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
     a.number = change.getId().get();
@@ -174,12 +173,12 @@
    * @return object suitable for serialization to JSON
    */
   public RefUpdateAttribute asRefUpdateAttribute(
-      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
+      ObjectId oldId, ObjectId newId, BranchNameKey refName) {
     RefUpdateAttribute ru = new RefUpdateAttribute();
     ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
     ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
-    ru.project = refName.getParentKey().get();
-    ru.refName = refName.get();
+    ru.project = refName.project().get();
+    ru.refName = refName.branch();
     return ru;
   }
 
@@ -285,7 +284,7 @@
 
   private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
       throws IOException {
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
+    RevCommit commit = rw.parseCommit(currentPs.commitId());
     final List<String> parentNames = new ArrayList<>(commit.getParentCount());
     for (RevCommit p : commit.getParents()) {
       parentNames.add(p.name());
@@ -296,7 +295,7 @@
     for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
       for (PatchSet ps : cd.patchSets()) {
         for (String p : parentNames) {
-          if (!ps.getRevision().get().equals(p)) {
+          if (!ps.commitId().name().equals(p)) {
             continue;
           }
           ca.dependsOn.add(newDependsOn(requireNonNull(cd.change()), ps));
@@ -318,18 +317,18 @@
 
   private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
       throws IOException {
-    if (currentPs.getGroups().isEmpty()) {
+    if (currentPs.groups().isEmpty()) {
       return;
     }
-    String rev = currentPs.getRevision().get();
+    String rev = currentPs.commitId().name();
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
         InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, change.getProject(), currentPs.getGroups())) {
+            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        RevCommit commit = rw.parseCommit(ps.commitId());
         for (RevCommit p : commit.getParents()) {
           if (!p.name().equals(rev)) {
             continue;
@@ -343,7 +342,7 @@
 
   private DependencyAttribute newDependsOn(Change c, PatchSet ps) {
     DependencyAttribute d = newDependencyAttribute(c, ps);
-    d.isCurrentPatchSet = ps.getId().equals(c.currentPatchSetId());
+    d.isCurrentPatchSet = ps.id().equals(c.currentPatchSetId());
     return d;
   }
 
@@ -355,8 +354,8 @@
     DependencyAttribute d = new DependencyAttribute();
     d.number = c.getId().get();
     d.id = c.getKey().toString();
-    d.revision = ps.getRevision().get();
-    d.ref = ps.getRefName();
+    d.revision = ps.commitId().name();
+    d.ref = ps.refName();
     return d;
   }
 
@@ -400,7 +399,7 @@
       for (PatchSet p : ps) {
         PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p);
         if (approvals != null) {
-          addApprovals(psa, p.getId(), approvals, labelTypes);
+          addApprovals(psa, p.id(), approvals, labelTypes);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
@@ -463,12 +462,12 @@
    */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
-    p.revision = patchSet.getRevision().get();
-    p.number = patchSet.getPatchSetId();
-    p.ref = patchSet.getRefName();
-    p.uploader = asAccountAttribute(patchSet.getUploader());
-    p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
-    PatchSet.Id pId = patchSet.getId();
+    p.revision = patchSet.commitId().name();
+    p.number = patchSet.number();
+    p.ref = patchSet.refName();
+    p.uploader = asAccountAttribute(patchSet.uploader());
+    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
       RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
@@ -476,7 +475,7 @@
         p.parents.add(parent.name());
       }
 
-      UserIdentity author = toUserIdentity(c.getAuthorIdent());
+      UserIdentity author = emails.toUserIdentity(c.getAuthorIdent());
       if (author.getAccount() == null) {
         p.author = new AccountAttribute();
         p.author.email = author.getEmail();
@@ -495,7 +494,7 @@
       }
       p.kind = changeKindCache.getChangeKind(change, patchSet);
     } catch (IOException | StorageException e) {
-      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.getId());
+      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Cannot get size information for %s: %s", pId, e.getMessage());
     } catch (PatchListNotAvailableException e) {
@@ -504,26 +503,6 @@
     return p;
   }
 
-  // TODO: The same method exists in PatchSetInfoFactory, find a common place
-  // for it
-  private UserIdentity toUserIdentity(PersonIdent who) throws IOException {
-    UserIdentity u = new UserIdentity();
-    u.setName(who.getName());
-    u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
-    u.setTimeZone(who.getTimeZoneOffset());
-
-    // If only one account has access to this email address, select it
-    // as the identity of the user.
-    //
-    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
-    if (a.size() == 1) {
-      u.setAccount(a.iterator().next());
-    }
-
-    return u;
-  }
-
   public void addApprovals(
       PatchSetAttribute p,
       PatchSet.Id id,
@@ -540,7 +519,7 @@
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
-        if (a.getValue() != 0) {
+        if (a.value() != 0) {
           p.approvals.add(asApprovalAttribute(a, labelTypes));
         }
       }
@@ -571,9 +550,9 @@
    */
   public AccountAttribute asAccountAttribute(AccountState accountState) {
     AccountAttribute who = new AccountAttribute();
-    who.name = accountState.getAccount().getFullName();
-    who.email = accountState.getAccount().getPreferredEmail();
-    who.username = accountState.getUserName().orElse(null);
+    who.name = accountState.account().fullName();
+    who.email = accountState.account().preferredEmail();
+    who.username = accountState.userName().orElse(null);
     return who;
   }
 
@@ -599,13 +578,13 @@
    */
   public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
     ApprovalAttribute a = new ApprovalAttribute();
-    a.type = approval.getLabelId().get();
-    a.value = Short.toString(approval.getValue());
-    a.by = asAccountAttribute(approval.getAccountId());
-    a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.type = approval.labelId().get();
+    a.value = Short.toString(approval.value());
+    a.by = asAccountAttribute(approval.accountId());
+    a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.getLabelId());
+    LabelType lt = labelTypes.byLabel(approval.labelId());
     if (lt != null) {
       a.description = lt.getName();
     }
diff --git a/java/com/google/gerrit/server/events/EventGson.java b/java/com/google/gerrit/server/events/EventGson.java
new file mode 100644
index 0000000..87b45f6
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGson.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+@Target({PARAMETER, FIELD})
+public @interface EventGson {}
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
new file mode 100644
index 0000000..688507b
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.change.ChangeKeyAdapter;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Provider;
+
+public class EventGsonProvider implements Provider<Gson> {
+  @Override
+  public Gson get() {
+    return new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventDeserializer())
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+        .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
+        .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .create();
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventsMetrics.java b/java/com/google/gerrit/server/events/EventsMetrics.java
index f73d6de..3c87cca 100644
--- a/java/com/google/gerrit/server/events/EventsMetrics.java
+++ b/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -31,7 +32,7 @@
         metricMaker.newCounter(
             "events",
             new Description("Triggered events").setRate().setUnit("triggered events"),
-            Field.ofString("type"));
+            Field.ofString("type", Metadata.Builder::eventType).build());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/events/HashtagsChangedEvent.java b/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
index 1de0fc3..d9e0445 100644
--- a/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
+++ b/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class HashtagsChangedEvent extends ChangeEvent {
diff --git a/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java b/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
index 8cea856..24f4709 100644
--- a/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
+++ b/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class PatchSetCreatedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/PatchSetEvent.java b/java/com/google/gerrit/server/events/PatchSetEvent.java
index f9dde66..c8e4591 100644
--- a/java/com/google/gerrit/server/events/PatchSetEvent.java
+++ b/java/com/google/gerrit/server/events/PatchSetEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.PatchSetAttribute;
 
 public class PatchSetEvent extends ChangeEvent {
diff --git a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
index d03eda4..2e6fe83 100644
--- a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class PrivateStateChangedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/ProjectCreatedEvent.java b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
index dc979ca..d092b39 100644
--- a/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
+++ b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 public class ProjectCreatedEvent extends ProjectEvent {
   static final String TYPE = "project-created";
@@ -27,7 +27,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   public String getHeadName() {
diff --git a/java/com/google/gerrit/server/events/ProjectEvent.java b/java/com/google/gerrit/server/events/ProjectEvent.java
index cba8e90..77085e6 100644
--- a/java/com/google/gerrit/server/events/ProjectEvent.java
+++ b/java/com/google/gerrit/server/events/ProjectEvent.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 public abstract class ProjectEvent extends Event {
   protected ProjectEvent(String type) {
diff --git a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
similarity index 60%
rename from java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
rename to java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
index 743b314..eeaa238 100644
--- a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
+++ b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
@@ -14,17 +14,31 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
 import com.google.gson.JsonPrimitive;
 import com.google.gson.JsonSerializationContext;
 import com.google.gson.JsonSerializer;
 import java.lang.reflect.Type;
 
-public class ProjectNameKeySerializer implements JsonSerializer<Project.NameKey> {
+public class ProjectNameKeyAdapter
+    implements JsonSerializer<Project.NameKey>, JsonDeserializer<Project.NameKey> {
   @Override
   public JsonElement serialize(
       Project.NameKey project, Type typeOfSrc, JsonSerializationContext context) {
     return new JsonPrimitive(project.get());
   }
+
+  @Override
+  public Project.NameKey deserialize(
+      JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.isJsonPrimitive() || !json.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + json);
+    }
+    return Project.nameKey(json.getAsString());
+  }
 }
diff --git a/java/com/google/gerrit/server/events/RefEvent.java b/java/com/google/gerrit/server/events/RefEvent.java
index 951940f..7a6bf6f 100644
--- a/java/com/google/gerrit/server/events/RefEvent.java
+++ b/java/com/google/gerrit/server/events/RefEvent.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.entities.BranchNameKey;
 
 public abstract class RefEvent extends ProjectEvent {
   protected RefEvent(String type) {
     super(type);
   }
 
-  public Branch.NameKey getBranchNameKey() {
-    return new Branch.NameKey(getProjectNameKey(), getRefName());
+  public BranchNameKey getBranchNameKey() {
+    return BranchNameKey.create(getProjectNameKey(), getRefName());
   }
 
   public abstract String getRefName();
diff --git a/java/com/google/gerrit/server/events/RefReceivedEvent.java b/java/com/google/gerrit/server/events/RefReceivedEvent.java
index aa02d11..18783aa 100644
--- a/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
diff --git a/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
index d740543..1ca6392 100644
--- a/java/com/google/gerrit/server/events/RefUpdatedEvent.java
+++ b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 
@@ -30,7 +30,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(refUpdate.get().project);
+    return Project.nameKey(refUpdate.get().project);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/events/ReviewerAddedEvent.java b/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
index ea6bda3..5d2b7a5 100644
--- a/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
+++ b/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ReviewerAddedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java b/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
index 02f9d43..2b0b6ac 100644
--- a/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
+++ b/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index f6f1d57..18b6a5e 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,6 +20,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -42,10 +46,6 @@
 import com.google.gerrit.extensions.events.VoteDeletedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -137,7 +137,7 @@
 
   private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(new Change.Id(info._number));
+      return changeNotesFactory.createChecked(Change.id(info._number));
     } catch (NoSuchChangeException e) {
       throw new StorageException(e);
     }
@@ -162,7 +162,7 @@
     return Suppliers.memoize(
         () ->
             account != null
-                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
+                ? eventFactory.asAccountAttribute(Account.id(account._accountId))
                 : null);
   }
 
@@ -361,7 +361,7 @@
     if (ev.getUpdater() != null) {
       event.submitter = accountAttributeSupplier(ev.getUpdater());
     }
-    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    final BranchNameKey refName = BranchNameKey.create(ev.getProjectName(), ev.getRefName());
     event.refUpdate =
         Suppliers.memoize(
             () ->
diff --git a/java/com/google/gerrit/server/events/TopicChangedEvent.java b/java/com/google/gerrit/server/events/TopicChangedEvent.java
index 0b6ecc5..b9cce66 100644
--- a/java/com/google/gerrit/server/events/TopicChangedEvent.java
+++ b/java/com/google/gerrit/server/events/TopicChangedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class TopicChangedEvent extends ChangeEvent {
diff --git a/java/com/google/gerrit/server/events/VoteDeletedEvent.java b/java/com/google/gerrit/server/events/VoteDeletedEvent.java
index 87c4c05..180b4a8 100644
--- a/java/com/google/gerrit/server/events/VoteDeletedEvent.java
+++ b/java/com/google/gerrit/server/events/VoteDeletedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
index 5e52c7b..74f997b 100644
--- a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class WorkInProgressStateChangedEvent extends PatchSetEvent {
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index fdce1da..a76b69b 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index a8c08b9..b27ffb9 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 9e3e979..df71f27 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 756f383..add1c51 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index e8bed56..fa91dbd 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index ccb17d5..5fb004f 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index ea9ae31..e45b206 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -22,8 +24,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index d00eb31..3dcf3b8 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -16,19 +16,21 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -39,42 +41,51 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 
+/**
+ * Formats change and revision info objects to serve as payload for Gerrit events.
+ *
+ * <p>Uses configurable options ({@code event.payload.listChangeOptions}) to decide which fields to
+ * populate.
+ */
 @Singleton
 public class EventUtil {
-  private static final ImmutableSet<ListChangesOption> CHANGE_OPTIONS;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final ImmutableSet<ListChangesOption> DEFAULT_CHANGE_OPTIONS;
 
   static {
     EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
-
     // Some options, like actions, are expensive to compute because they potentially have to walk
     // lots of history and inspect lots of other changes.
     opts.remove(ListChangesOption.CHANGE_ACTIONS);
     opts.remove(ListChangesOption.CURRENT_ACTIONS);
-
     // CHECK suppresses some exceptions on corrupt changes, which is not appropriate for passing
     // through the event system as we would rather let them propagate.
     opts.remove(ListChangesOption.CHECK);
-
-    CHANGE_OPTIONS = Sets.immutableEnumSet(opts);
+    DEFAULT_CHANGE_OPTIONS = Sets.immutableEnumSet(opts);
   }
 
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
   private final RevisionJson.Factory revisionJsonFactory;
+  private final ImmutableSet<ListChangesOption> changeOptions;
 
   @Inject
   EventUtil(
       ChangeJson.Factory changeJsonFactory,
       RevisionJson.Factory revisionJsonFactory,
-      ChangeData.Factory changeDataFactory) {
+      ChangeData.Factory changeDataFactory,
+      @GerritServerConfig Config gerritConfig) {
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
     this.revisionJsonFactory = revisionJsonFactory;
+    this.changeOptions = parseChangeListOptions(gerritConfig);
   }
 
   public ChangeInfo changeInfo(Change change) {
-    return changeJsonFactory.create(CHANGE_OPTIONS).format(change);
+    return changeJsonFactory.create(changeOptions).format(change);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
@@ -84,19 +95,19 @@
 
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
-    ChangeData cd = changeDataFactory.create(project, ps.getId().getParentKey());
-    return revisionJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
+    ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
+    return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
   public AccountInfo accountInfo(AccountState accountState) {
-    if (accountState == null || accountState.getAccount().getId() == null) {
+    if (accountState == null || accountState.account().id() == null) {
       return null;
     }
-    Account account = accountState.getAccount();
-    AccountInfo accountInfo = new AccountInfo(account.getId().get());
-    accountInfo.email = account.getPreferredEmail();
-    accountInfo.name = account.getFullName();
-    accountInfo.username = accountState.getUserName().orElse(null);
+    Account account = accountState.account();
+    AccountInfo accountInfo = new AccountInfo(account.id().get());
+    accountInfo.email = account.preferredEmail();
+    accountInfo.name = account.fullName();
+    accountInfo.username = accountState.userName().orElse(null);
     return accountInfo;
   }
 
@@ -106,9 +117,25 @@
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
       result.put(
-          e.getKey(),
-          new ApprovalInfo(accountState.getAccount().getId().get(), value, null, null, ts));
+          e.getKey(), new ApprovalInfo(accountState.account().id().get(), value, null, null, ts));
     }
     return result;
   }
+
+  private static ImmutableSet<ListChangesOption> parseChangeListOptions(Config gerritConfig) {
+    String[] config = gerritConfig.getStringList("event", "payload", "listChangeOptions");
+    if (config.length == 0) {
+      return DEFAULT_CHANGE_OPTIONS;
+    }
+
+    ImmutableSet.Builder<ListChangesOption> result = ImmutableSet.builder();
+    for (String c : config) {
+      try {
+        result.add(ListChangesOption.valueOf(c));
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log("could not parse list change option %s", c);
+      }
+    }
+    return result.build();
+  }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index bae17e7..99f105e 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 65f5b8b..df60ec0 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -16,12 +16,12 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index 49a6091..72adff7 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index f9f67f6..1af428cb 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -16,14 +16,14 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index b92f3e6..61632f2 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -22,8 +24,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 6fddcfe..bdfa8c1 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 9e1ae44..cb982a1 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.TopicEditedListener;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index bd6873a..8533a65 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -22,8 +24,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 785d6fe..16c5e25 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 3ca2bdb..0bc3d5c 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -19,7 +19,6 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Predicate;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -38,6 +37,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendCondition;
@@ -50,6 +50,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Predicate;
 
 @Singleton
 public class UiActions {
@@ -71,7 +72,7 @@
             new com.google.gerrit.metrics.Description("Latency for RestView#getDescription calls")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofString("view"));
+            Field.ofString("view", Metadata.Builder::restViewName).build());
   }
 
   public <R extends RestResource> Iterable<UiAction.Description> from(
@@ -143,7 +144,7 @@
 
     String name = e.getExportName().substring(d + 1);
     UiAction.Description dsc;
-    try (Timer1.Context ignored = uiActionLatency.start(name)) {
+    try (Timer1.Context<String> ignored = uiActionLatency.start(name)) {
       dsc = ((UiAction<R>) view).getDescription(resource);
     }
 
diff --git a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index 65f14db..a1682fe 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -18,11 +18,11 @@
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.FixReplacement;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.TreeModification;
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index d8aeece..4473ab7 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.git;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_REJECT_COMMITS;
+import static com.google.gerrit.entities.RefNames.REFS_REJECT_COMMITS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/server/git/BranchOrderSection.java b/java/com/google/gerrit/server/git/BranchOrderSection.java
index d4b537e..4c77b61 100644
--- a/java/com/google/gerrit/server/git/BranchOrderSection.java
+++ b/java/com/google/gerrit/server/git/BranchOrderSection.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import java.util.List;
 
 public class BranchOrderSection {
diff --git a/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
index 7d4edcf..580c0b9 100644
--- a/java/com/google/gerrit/server/git/ChangeMessageModifier.java
+++ b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -47,5 +47,5 @@
    * @return a new not null commit message.
    */
   String onSubmit(
-      String newCommitMessage, RevCommit original, RevCommit mergeTip, Branch.NameKey destination);
+      String newCommitMessage, RevCommit original, RevCommit mergeTip, BranchNameKey destination);
 }
diff --git a/java/com/google/gerrit/server/git/ChangeReportFormatter.java b/java/com/google/gerrit/server/git/ChangeReportFormatter.java
index 3ce6b2b..f897a1d 100644
--- a/java/com/google/gerrit/server/git/ChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/ChangeReportFormatter.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 
 public interface ChangeReportFormatter {
   @AutoValue
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index c210dcd..d7538ba 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
@@ -48,7 +48,7 @@
       Ordering.natural()
           .onResultOf(
               (CodeReviewCommit c) ->
-                  c.getPatchsetId() != null ? c.getPatchsetId().getParentKey().get() : null)
+                  c.getPatchsetId() != null ? c.getPatchsetId().changeId().get() : null)
           .nullsFirst();
 
   public static CodeReviewRevWalk newRevWalk(Repository repo) {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index b0f10f2..476037b 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -14,16 +14,49 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.change.ChangeMessages;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
 import java.util.ArrayList;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
 
 /** Static utilities for working with {@link RevCommit}s. */
+@Singleton
 public class CommitUtil {
+  private final GitRepositoryManager repoManager;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  CommitUtil(
+      GitRepositoryManager repoManager, @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    this.repoManager = repoManager;
+    this.serverIdent = serverIdent;
+  }
+
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
     return toCommitInfo(commit, null);
   }
@@ -47,5 +80,84 @@
     return info;
   }
 
-  private CommitUtil() {}
+  /**
+   * Allows creating a revert commit.
+   *
+   * @param message Commit message for the revert commit.
+   * @param notes ChangeNotes of the change being reverted.
+   * @param user Current User performing the revert.
+   * @return ObjectId that represents the newly created commit.
+   * @throws ResourceConflictException Can't revert the initial commit.
+   * @throws IOException Thrown in case of I/O errors.
+   */
+  public ObjectId createRevertCommit(String message, ChangeNotes notes, CurrentUser user)
+      throws ResourceConflictException, IOException {
+    message = Strings.emptyToNull(message);
+
+    Project.NameKey project = notes.getProjectName();
+    try (Repository git = repoManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      return createRevertCommit(message, notes, user, null, TimeUtil.nowTs(), oi, revWalk);
+    }
+  }
+
+  /**
+   * @param message Commit message for the revert commit.
+   * @param notes ChangeNotes of the change being reverted.
+   * @param user Current User performing the revert.
+   * @param generatedChangeId The changeId for the commit message, can be null since it is not
+   *     needed for commits, only for changes.
+   * @param ts Timestamp of creation for the commit.
+   * @param oi ObjectInserter for inserting the newly created commit.
+   * @param revWalk Used for parsing the original commit.
+   * @return ObjectId that represents the newly created commit.
+   * @throws ResourceConflictException Can't revert the initial commit.
+   * @throws IOException Thrown in case of I/O errors.
+   */
+  public ObjectId createRevertCommit(
+      String message,
+      ChangeNotes notes,
+      CurrentUser user,
+      @Nullable ObjectId generatedChangeId,
+      Timestamp ts,
+      ObjectInserter oi,
+      RevWalk revWalk)
+      throws ResourceConflictException, IOException {
+
+    PatchSet patch = notes.getCurrentPatchSet();
+    RevCommit commitToRevert = revWalk.parseCommit(patch.commitId());
+    if (commitToRevert.getParentCount() == 0) {
+      throw new ResourceConflictException("Cannot revert initial commit");
+    }
+
+    PersonIdent committerIdent = serverIdent.get();
+    PersonIdent authorIdent =
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+
+    RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
+    revWalk.parseHeaders(parentToCommitToRevert);
+
+    CommitBuilder revertCommitBuilder = new CommitBuilder();
+    revertCommitBuilder.addParentId(commitToRevert);
+    revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
+    revertCommitBuilder.setAuthor(authorIdent);
+    revertCommitBuilder.setCommitter(authorIdent);
+
+    Change changeToRevert = notes.getChange();
+    if (message == null) {
+      message =
+          MessageFormat.format(
+              ChangeMessages.get().revertChangeDefaultMessage,
+              changeToRevert.getSubject(),
+              patch.commitId().name());
+    }
+    if (generatedChangeId != null) {
+      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+    }
+    ObjectId id = oi.insert(revertCommitBuilder);
+    oi.flush();
+    return id;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
deleted file mode 100644
index a8594e7..0000000
--- a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-
-/**
- * Wrapper around {@link com.google.gerrit.server.permissions.PermissionBackend.ForProject} that
- * implements {@link org.eclipse.jgit.transport.AdvertiseRefsHook}.
- */
-public class DefaultAdvertiseRefsHook extends AbstractAdvertiseRefsHook {
-  private final PermissionBackend.ForProject perm;
-  private final PermissionBackend.RefFilterOptions opts;
-
-  public DefaultAdvertiseRefsHook(
-      PermissionBackend.ForProject perm, PermissionBackend.RefFilterOptions opts) {
-    this.perm = perm;
-    this.opts = opts;
-  }
-
-  @Override
-  protected Map<String, Ref> getAdvertisedRefs(Repository repo, RevWalk revWalk)
-      throws ServiceMayNotContinueException {
-    try {
-      List<String> prefixes =
-          !opts.prefixes().isEmpty() ? opts.prefixes() : ImmutableList.of(RefDatabase.ALL);
-      return perm.filter(
-          repo.getRefDatabase().getRefsByPrefix(prefixes.toArray(new String[0])), repo, opts);
-    } catch (IOException | PermissionBackendException e) {
-      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-      ex.initCause(e);
-      throw ex;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index 5b9dffc..5866c57 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.inject.Inject;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/git/DelegateRefDatabase.java b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
new file mode 100644
index 0000000..34dd6a9
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Wrapper around {@link RefDatabase} that delegates all calls to the wrapped {@link RefDatabase}.
+ */
+public class DelegateRefDatabase extends RefDatabase {
+
+  private Repository delegate;
+
+  DelegateRefDatabase(Repository delegate) {
+    this.delegate = delegate;
+  }
+
+  @Override
+  public void create() throws IOException {
+    delegate.getRefDatabase().create();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public boolean isNameConflicting(String name) throws IOException {
+    return delegate.getRefDatabase().isNameConflicting(name);
+  }
+
+  @Override
+  public RefUpdate newUpdate(String name, boolean detach) throws IOException {
+    return delegate.getRefDatabase().newUpdate(name, detach);
+  }
+
+  @Override
+  public RefRename newRename(String fromName, String toName) throws IOException {
+    return delegate.getRefDatabase().newRename(fromName, toName);
+  }
+
+  @Override
+  public Ref exactRef(String name) throws IOException {
+    return delegate.getRefDatabase().exactRef(name);
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  public Map<String, Ref> getRefs(String prefix) throws IOException {
+    return delegate.getRefDatabase().getRefs(prefix);
+  }
+
+  @Override
+  public List<Ref> getAdditionalRefs() throws IOException {
+    return delegate.getRefDatabase().getAdditionalRefs();
+  }
+
+  @Override
+  public Ref peel(Ref ref) throws IOException {
+    return delegate.getRefDatabase().peel(ref);
+  }
+
+  Repository getDelegate() {
+    return delegate;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
new file mode 100644
index 0000000..800490d
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.IOException;
+import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.lib.BaseRepositoryBuilder;
+import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+
+/** Wrapper around {@link Repository} that delegates all calls to the wrapped {@link Repository}. */
+class DelegateRepository extends Repository {
+
+  private final Repository delegate;
+
+  DelegateRepository(Repository delegate) {
+    super(toBuilder(delegate));
+    this.delegate = delegate;
+  }
+
+  @Override
+  public void create(boolean bare) throws IOException {
+    delegate.create(bare);
+  }
+
+  @Override
+  public String getIdentifier() {
+    return delegate.getIdentifier();
+  }
+
+  @Override
+  public ObjectDatabase getObjectDatabase() {
+    return delegate.getObjectDatabase();
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return delegate.getRefDatabase();
+  }
+
+  @Override
+  public StoredConfig getConfig() {
+    return delegate.getConfig();
+  }
+
+  @Override
+  public AttributesNodeProvider createAttributesNodeProvider() {
+    return delegate.createAttributesNodeProvider();
+  }
+
+  @Override
+  public void scanForRepoChanges() throws IOException {
+    delegate.scanForRepoChanges();
+  }
+
+  @Override
+  public void notifyIndexChanged(boolean internal) {
+    delegate.notifyIndexChanged(internal);
+  }
+
+  @Override
+  public ReflogReader getReflogReader(String refName) throws IOException {
+    return delegate.getReflogReader(refName);
+  }
+
+  @SuppressWarnings("rawtypes")
+  private static BaseRepositoryBuilder toBuilder(Repository repo) {
+    if (!repo.isBare()) {
+      throw new IllegalArgumentException("non-bare repository is not supported");
+    }
+
+    return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
+  }
+}
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 75c9012..090d439 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -17,8 +17,8 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GcConfig;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
diff --git a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
index 31cd880..e3a923b 100644
--- a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
+++ b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.HashSet;
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
index f8d8a49..2697eee 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index e4fd803..dd39198 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -28,8 +28,8 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -82,9 +82,9 @@
     if (rsrc.getEdit().isPresent()) {
       // Groups for an edit are just the base revision's groups, since they have
       // the same parent.
-      return rsrc.getEdit().get().getBasePatchSet().getGroups();
+      return rsrc.getEdit().get().getBasePatchSet().groups();
     }
-    return rsrc.getPatchSet().getGroups();
+    return rsrc.getPatchSet().groups();
   }
 
   private interface Lookup {
@@ -107,9 +107,9 @@
         transformRefs(changeRefsById),
         psId -> {
           // TODO(dborowitz): Reuse open repository from caller.
-          ChangeNotes notes = notesFactory.createChecked(project, psId.getParentKey());
+          ChangeNotes notes = notesFactory.createChecked(project, psId.changeId());
           PatchSet ps = psUtil.get(notes, psId);
-          return ps != null ? ps.getGroups() : null;
+          return ps != null ? ps.groups() : null;
         });
   }
 
diff --git a/java/com/google/gerrit/server/git/HookUtil.java b/java/com/google/gerrit/server/git/HookUtil.java
index 3bef7cc..fd29c8deb 100644
--- a/java/com/google/gerrit/server/git/HookUtil.java
+++ b/java/com/google/gerrit/server/git/HookUtil.java
@@ -19,8 +19,9 @@
 import java.io.IOException;
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
 
 /** Static utilities for writing git protocol hooks. */
 public class HookUtil {
@@ -32,8 +33,7 @@
    * @return map of refs that were advertised.
    * @throws ServiceMayNotContinueException if a problem occurred.
    */
-  @SuppressWarnings("deprecation")
-  public static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
+  public static Map<String, Ref> ensureAllRefsAdvertised(ReceivePack rp)
       throws ServiceMayNotContinueException {
     Map<String, Ref> refs = rp.getAdvertisedRefs();
     if (refs != null) {
@@ -52,5 +52,32 @@
     return refs;
   }
 
+  /**
+   * Scan and advertise all refs in the repo if refs have not already been advertised; otherwise,
+   * just return the advertised map.
+   *
+   * @param up upload-pack handler.
+   * @return map of refs that were advertised.
+   * @throws ServiceMayNotContinueException if a problem occurred.
+   */
+  public static Map<String, Ref> ensureAllRefsAdvertised(UploadPack up)
+      throws ServiceMayNotContinueException {
+    Map<String, Ref> refs = up.getAdvertisedRefs();
+    if (refs != null) {
+      return refs;
+    }
+    try {
+      refs =
+          up.getRepository().getRefDatabase().getRefs().stream()
+              .collect(toMap(Ref::getName, r -> r));
+    } catch (ServiceMayNotContinueException e) {
+      throw e;
+    } catch (IOException e) {
+      throw new ServiceMayNotContinueException(e);
+    }
+    up.setAdvertisedRefs(refs);
+    return refs;
+  }
+
   private HookUtil() {}
 }
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 85822a8..44d1077 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -254,7 +254,7 @@
       int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
       projectName = projectName.substring(0, newLen);
     }
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index c1d8ddd..ad87843 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -17,19 +17,30 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -38,13 +49,6 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -67,7 +71,6 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -117,6 +120,13 @@
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * Length of abbreviated hex SHA-1s in merged filenames.
+   *
+   * <p>This is a constant so output is stable over time even if the SHA-1 prefix becomes ambiguous.
+   */
+  private static final int NAME_ABBREV_LEN = 6;
+
   static class PluggableCommitMessageGenerator {
     private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
@@ -126,7 +136,7 @@
     }
 
     public String generate(
-        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String originalMessage) {
+        RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
       requireNonNull(original.getRawBuffer());
       if (mergeTip != null) {
         requireNonNull(mergeTip.getRawBuffer());
@@ -256,7 +266,8 @@
       boolean ignoreIdenticalTree,
       boolean allowConflicts)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException {
+          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException,
+          InvalidMergeStrategyException {
 
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
@@ -337,13 +348,13 @@
         String.format(
             "%0$-" + nameLength + "s (%s %s)",
             oursName,
-            ours.abbreviate(6).name(),
+            abbreviateName(ours, NAME_ABBREV_LEN),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
             "%0$-" + nameLength + "s (%s %s)",
             theirsName,
-            theirs.abbreviate(6).name(),
+            abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
 
     MergeFormatter fmt = new MergeFormatter();
@@ -422,7 +433,8 @@
       PersonIdent committerIndent,
       String commitMsg,
       RevWalk rw)
-      throws IOException, MergeIdenticalTreeException, MergeConflictException {
+      throws IOException, MergeIdenticalTreeException, MergeConflictException,
+          InvalidMergeStrategyException {
 
     if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy)
         && rw.isMergedInto(originalCommit, mergeTip)) {
@@ -450,7 +462,7 @@
   }
 
   public static String createConflictMessage(List<String> conflicts) {
-    StringBuilder sb = new StringBuilder("merge conflict(s)");
+    StringBuilder sb = new StringBuilder("merge conflict(s):");
     for (String c : conflicts) {
       sb.append('\n').append(c);
     }
@@ -514,7 +526,7 @@
     PatchSetApproval submitAudit = null;
 
     for (PatchSetApproval a : safeGetApprovals(notes, psId)) {
-      if (a.getValue() <= 0) {
+      if (a.value() <= 0) {
         // Negative votes aren't counted.
         continue;
       }
@@ -522,29 +534,29 @@
       if (a.isLegacySubmit()) {
         // Submit is treated specially, below (becomes committer)
         //
-        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
+        if (submitAudit == null || a.granted().compareTo(submitAudit.granted()) > 0) {
           submitAudit = a;
         }
         continue;
       }
 
-      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
+      final Account acc = identifiedUserFactory.create(a.accountId()).getAccount();
       final StringBuilder identbuf = new StringBuilder();
-      if (acc.getFullName() != null && acc.getFullName().length() > 0) {
+      if (acc.fullName() != null && acc.fullName().length() > 0) {
         if (identbuf.length() > 0) {
           identbuf.append(' ');
         }
-        identbuf.append(acc.getFullName());
+        identbuf.append(acc.fullName());
       }
-      if (acc.getPreferredEmail() != null && acc.getPreferredEmail().length() > 0) {
-        if (isSignedOffBy(footers, acc.getPreferredEmail())) {
+      if (acc.preferredEmail() != null && acc.preferredEmail().length() > 0) {
+        if (isSignedOffBy(footers, acc.preferredEmail())) {
           continue;
         }
         if (identbuf.length() > 0) {
           identbuf.append(' ');
         }
         identbuf.append('<');
-        identbuf.append(acc.getPreferredEmail());
+        identbuf.append(acc.preferredEmail());
         identbuf.append('>');
       }
       if (identbuf.length() == 0) {
@@ -553,12 +565,12 @@
       }
 
       final String tag;
-      if (isCodeReview(a.getLabelId())) {
+      if (isCodeReview(a.labelId())) {
         tag = "Reviewed-by";
-      } else if (isVerified(a.getLabelId())) {
+      } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
+        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
         if (lt == null) {
           continue;
         }
@@ -733,10 +745,10 @@
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
       Config repoConfig,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
-      throws IntegrationException {
+      throws IntegrationException, InvalidMergeStrategyException {
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(mergeTip, n)) {
@@ -789,7 +801,7 @@
       PersonIdent committer,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       ObjectId treeId,
       CodeReviewCommit n)
@@ -806,9 +818,9 @@
     }
 
     StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
-    if (!R_HEADS_MASTER.equals(destBranch.get())) {
+    if (!R_HEADS_MASTER.equals(destBranch.branch())) {
       msgbuf.append(" into ");
-      msgbuf.append(destBranch.getShortName());
+      msgbuf.append(destBranch.shortName());
     }
 
     if (merged.size() > 1) {
@@ -840,29 +852,26 @@
       return String.format("Merge \"%s\"", c.getShortMessage());
     }
 
-    LinkedHashSet<String> topics = new LinkedHashSet<>(4);
-    for (CodeReviewCommit c : merged) {
-      if (!Strings.isNullOrEmpty(c.change().getTopic())) {
-        topics.add(c.change().getTopic());
-      }
-    }
+    ImmutableSortedSet<String> topics =
+        merged.stream()
+            .map(c -> c.change().getTopic())
+            .filter(t -> !Strings.isNullOrEmpty(t))
+            .map(t -> "\"" + t + "\"")
+            .collect(toImmutableSortedSet(naturalOrder()));
 
-    if (topics.size() == 1) {
-      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
-    } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
-    } else {
+    if (!topics.isEmpty()) {
       return String.format(
-          "Merge changes %s%s",
-          FluentIterable.from(merged)
-              .limit(5)
-              .transform(c -> c.change().getKey().abbreviate())
-              .join(Joiner.on(',')),
-          merged.size() > 5 ? ", ..." : "");
+          "Merge changes from topic%s %s",
+          topics.size() > 1 ? "s" : "", topics.stream().collect(joining(", ")));
     }
+    return merged.stream()
+        .limit(5)
+        .map(c -> c.change().getKey().abbreviate())
+        .collect(joining(",", "Merge changes ", merged.size() > 5 ? ", ..." : ""));
   }
 
-  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig)
+      throws InvalidMergeStrategyException {
     return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
   }
 
@@ -886,7 +895,8 @@
   }
 
   public static ThreeWayMerger newThreeWayMerger(
-      ObjectInserter inserter, Config repoConfig, String strategyName) {
+      ObjectInserter inserter, Config repoConfig, String strategyName)
+      throws InvalidMergeStrategyException {
     Merger m = newMerger(inserter, repoConfig, strategyName);
     checkArgument(
         m instanceof ThreeWayMerger,
@@ -895,9 +905,12 @@
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName)
+      throws InvalidMergeStrategyException {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
-    checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
+    if (strategy == null) {
+      throw new InvalidMergeStrategyException(strategyName);
+    }
     return strategy.newMerger(
         new ObjectInserter.Filter() {
           @Override
@@ -975,7 +988,7 @@
         if (c.getPatchsetId() == null) {
           continue;
         }
-        Change.Id id = c.getPatchsetId().getParentKey();
+        Change.Id id = c.getPatchsetId().changeId();
         if (!expected.contains(id)) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 3d64b82..858a55a 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -17,13 +17,11 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -42,7 +40,6 @@
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -112,7 +109,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws IOException {
     change = ctx.getChange();
-    correctBranch = refName.equals(change.getDest().get());
+    correctBranch = refName.equals(change.getDest().branch());
     if (!correctBranch) {
       return false;
     }
@@ -145,7 +142,7 @@
     }
     StringBuilder msgBuf = new StringBuilder();
     msgBuf.append("Change has been successfully pushed");
-    if (!refName.equals(change.getDest().get())) {
+    if (!refName.equals(change.getDest().branch())) {
       msgBuf.append(" into ");
       if (refName.startsWith(Constants.R_HEADS)) {
         msgBuf.append("branch ");
@@ -159,12 +156,7 @@
         ChangeMessagesUtil.newMessage(
             psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     cmUtil.addChangeMessage(update, msg);
-
-    PatchSetApproval submitter =
-        ApprovalsUtil.newApproval(
-            change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
-
+    update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
@@ -182,7 +174,7 @@
                   public void run() {
                     try {
                       MergedSender cm =
-                          mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
                       cm.setFrom(ctx.getAccountId());
                       cm.setPatchSet(patchSet, info);
                       cm.send();
@@ -203,8 +195,7 @@
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
-    RevCommit commit =
-        rw.parseCommit(ObjectId.fromString(requireNonNull(patchSet).getRevision().get()));
+    RevCommit commit = rw.parseCommit(requireNonNull(patchSet).commitId());
     return patchSetInfoFactory.get(rw, commit, psId);
   }
 }
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index 01e85cf..32b5bb8 100644
--- a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index b72ea92..bfc5135 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -346,6 +346,8 @@
         out.write(Constants.encode(s.toString()));
         out.flush();
       } catch (IOException e) {
+        logger.atWarning().withCause(e).log(
+            "Sending progress to client failed. Stop sending updates for task %s", taskName);
         write = false;
       }
     }
diff --git a/java/com/google/gerrit/server/git/NotesBranchUtil.java b/java/com/google/gerrit/server/git/NotesBranchUtil.java
index 1636b85..8cb2b40 100644
--- a/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
index 7f74f54..429f15a 100644
--- a/java/com/google/gerrit/server/git/NotifyConfig.java
+++ b/java/com/google/gerrit/server/git/NotifyConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.mail.Address;
@@ -113,6 +114,13 @@
 
   @Override
   public String toString() {
-    return "NotifyConfig[" + name + " = " + addresses + " + " + groups + "]";
+    return MoreObjects.toStringHelper(this)
+        .add("name", name)
+        .add("addresses", addresses)
+        .add("groups", groups)
+        .add("header", header)
+        .add("types", types)
+        .add("filter", filter)
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
new file mode 100644
index 0000000..c68842d
--- /dev/null
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -0,0 +1,185 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Wrapper around {@link DelegateRefDatabase} that filters all refs using {@link
+ * com.google.gerrit.server.permissions.PermissionBackend}.
+ */
+public class PermissionAwareReadOnlyRefDatabase extends DelegateRefDatabase {
+
+  private final PermissionBackend.ForProject forProject;
+
+  @Inject
+  PermissionAwareReadOnlyRefDatabase(
+      Repository delegateRepository, PermissionBackend.ForProject forProject) {
+    super(delegateRepository);
+    this.forProject = forProject;
+  }
+
+  @Override
+  public boolean isNameConflicting(String name) {
+    throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
+  }
+
+  @Override
+  public RefUpdate newUpdate(String name, boolean detach) {
+    throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
+  }
+
+  @Override
+  public RefRename newRename(String fromName, String toName) {
+    throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
+  }
+
+  @Override
+  public Ref exactRef(String name) throws IOException {
+    Ref ref = getDelegate().getRefDatabase().exactRef(name);
+    if (ref == null) {
+      return null;
+    }
+
+    Map<String, Ref> result;
+    try {
+      result =
+          forProject.filter(ImmutableMap.of(name, ref), getDelegate(), RefFilterOptions.defaults());
+    } catch (PermissionBackendException e) {
+      if (e.getCause() instanceof IOException) {
+        throw (IOException) e.getCause();
+      }
+      throw new IOException(e);
+    }
+    if (result.isEmpty()) {
+      return null;
+    }
+
+    Preconditions.checkState(
+        result.size() == 1, "Only one element expected, but was: " + result.size());
+    return Iterables.getOnlyElement(result.values());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  public Map<String, Ref> getRefs(String prefix) throws IOException {
+    Map<String, Ref> refs = getDelegate().getRefDatabase().getRefs(prefix);
+    if (refs.isEmpty()) {
+      return refs;
+    }
+
+    Map<String, Ref> result;
+    try {
+      // The security filtering assumes to receive the same refMap, independently from the ref
+      // prefix offset
+      result =
+          forProject.filter(
+              prefixIndependentRefMap(prefix, refs), getDelegate(), RefFilterOptions.defaults());
+    } catch (PermissionBackendException e) {
+      throw new IOException("", e);
+    }
+    return applyPrefixRefMap(prefix, result);
+  }
+
+  private Map<String, Ref> prefixIndependentRefMap(String prefix, Map<String, Ref> refs) {
+    if (prefix.length() > 0) {
+      return refs.values().stream().collect(Collectors.toMap(Ref::getName, Function.identity()));
+    }
+
+    return refs;
+  }
+
+  private Map<String, Ref> applyPrefixRefMap(String prefix, Map<String, Ref> refs) {
+    int prefixSlashPos = prefix.lastIndexOf('/') + 1;
+    if (prefixSlashPos > 0) {
+      return refs.values().stream()
+          .collect(
+              Collectors.toMap(
+                  (Ref ref) -> ref.getName().substring(prefixSlashPos), Function.identity()));
+    }
+
+    return refs;
+  }
+
+  @Override
+  public List<Ref> getRefsByPrefix(String prefix) throws IOException {
+    Map<String, Ref> coarseRefs;
+    int lastSlash = prefix.lastIndexOf('/');
+    if (lastSlash == -1) {
+      coarseRefs = getRefs(ALL);
+    } else {
+      coarseRefs = getRefs(prefix.substring(0, lastSlash + 1));
+    }
+
+    List<Ref> result;
+    if (lastSlash + 1 == prefix.length()) {
+      result = coarseRefs.values().stream().collect(toList());
+    } else {
+      String p = prefix.substring(lastSlash + 1);
+      result =
+          coarseRefs.entrySet().stream()
+              .filter(e -> e.getKey().startsWith(p))
+              .map(e -> e.getValue())
+              .collect(toList());
+    }
+    return Collections.unmodifiableList(result);
+  }
+
+  @Override
+  @NonNull
+  public Map<String, Ref> exactRef(String... refs) throws IOException {
+    Map<String, Ref> result = new HashMap<>(refs.length);
+    for (String name : refs) {
+      Ref ref = exactRef(name);
+      if (ref != null) {
+        result.put(name, ref);
+      }
+    }
+    return result;
+  }
+
+  @Override
+  @Nullable
+  public Ref firstExactRef(String... refs) throws IOException {
+    for (String name : refs) {
+      Ref ref = exactRef(name);
+      if (ref != null) {
+        return ref;
+      }
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/PermissionAwareRepository.java b/java/com/google/gerrit/server/git/PermissionAwareRepository.java
new file mode 100644
index 0000000..bb80cb5
--- /dev/null
+++ b/java/com/google/gerrit/server/git/PermissionAwareRepository.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.server.permissions.PermissionBackend;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Wrapper around {@link DelegateRepository} that overwrites {@link #getRefDatabase()} to return a
+ * {@link PermissionAwareReadOnlyRefDatabase}.
+ */
+public class PermissionAwareRepository extends DelegateRepository {
+
+  private final PermissionAwareReadOnlyRefDatabase permissionAwareReadOnlyRefDatabase;
+
+  public PermissionAwareRepository(Repository delegate, PermissionBackend.ForProject forProject) {
+    super(delegate);
+    this.permissionAwareReadOnlyRefDatabase =
+        new PermissionAwareReadOnlyRefDatabase(delegate, forProject);
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return permissionAwareReadOnlyRefDatabase;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/PermissionAwareRepositoryManager.java b/java/com/google/gerrit/server/git/PermissionAwareRepositoryManager.java
new file mode 100644
index 0000000..b11aa49
--- /dev/null
+++ b/java/com/google/gerrit/server/git/PermissionAwareRepositoryManager.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.base.Preconditions;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Wraps and unwraps existing repositories and makes them permission-aware by returning a {@link
+ * PermissionAwareReadOnlyRefDatabase}.
+ */
+public class PermissionAwareRepositoryManager {
+  public static Repository wrap(Repository delegate, PermissionBackend.ForProject forProject) {
+    Preconditions.checkState(
+        !(delegate instanceof PermissionAwareRepository),
+        "Cannot wrap PermissionAwareRepository instance");
+    return new PermissionAwareRepository(delegate, forProject);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/ProjectRunnable.java b/java/com/google/gerrit/server/git/ProjectRunnable.java
index 23d2326..e74bf2d 100644
--- a/java/com/google/gerrit/server/git/ProjectRunnable.java
+++ b/java/com/google/gerrit/server/git/ProjectRunnable.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 /** Used to retrieve the project name from an operation * */
 public interface ProjectRunnable extends Runnable {
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 16ac87f..9f9530c 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -18,15 +18,16 @@
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.cache.proto.Cache.PureRevertKeyProto;
 import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
@@ -35,7 +36,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import com.google.protobuf.ByteString;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -49,6 +49,7 @@
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /** Computes and caches if a change is a pure revert of another change. */
 @Singleton
@@ -97,8 +98,8 @@
             claimedRevert.getProjectName(), claimedRevert.getChange().getRevertOf());
     return isPureRevert(
         claimedRevert.getProjectName(),
-        ObjectId.fromString(claimedRevert.getCurrentPatchSet().getRevision().get()),
-        ObjectId.fromString(claimedOriginal.getCurrentPatchSet().getRevision().get()));
+        claimedRevert.getCurrentPatchSet().commitId(),
+        claimedOriginal.getCurrentPatchSet().commitId());
   }
 
   /**
@@ -151,10 +152,12 @@
     @Override
     public Boolean load(PureRevertKeyProto key) throws BadRequestException, IOException {
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer("Loading pure revert for %s", key)) {
+          TraceContext.newTimer(
+              "Loading pure revert",
+              Metadata.builder().cacheKey(key.toString()).projectName(key.getProject()).build())) {
         ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
         ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
-        Project.NameKey project = new Project.NameKey(key.getProject());
+        Project.NameKey project = Project.nameKey(key.getProject());
 
         try (Repository repo = repoManager.openRepository(project);
             ObjectInserter oi = repo.newObjectInserter();
@@ -185,9 +188,8 @@
           }
 
           // Any differences between claimed original's parent and the rebase result indicate that
-          // the
-          // claimedRevert is not a pure revert but made content changes
-          try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
+          // the claimedRevert is not a pure revert but made content changes
+          try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
             df.setReader(oi.newReader(), repo.getConfig());
             List<DiffEntry> entries =
                 df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
diff --git a/java/com/google/gerrit/server/git/ReceivePackInitializer.java b/java/com/google/gerrit/server/git/ReceivePackInitializer.java
index f374215..d7af280 100644
--- a/java/com/google/gerrit/server/git/ReceivePackInitializer.java
+++ b/java/com/google/gerrit/server/git/ReceivePackInitializer.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Project;
 import org.eclipse.jgit.transport.ReceivePack;
 
 @ExtensionPoint
diff --git a/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java b/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
index 3a7a125..8535cd2 100644
--- a/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
+++ b/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 /**
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index d7f8982..196fc61 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -20,14 +20,15 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -136,7 +137,7 @@
   @Override
   public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
     if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
-      cache.invalidate(new Project.NameKey(event.getProjectName()));
+      cache.invalidate(Project.nameKey(event.getProjectName()));
     }
   }
 
@@ -152,7 +153,9 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading changes of project %s", key);
+      try (TraceTimer timer =
+              TraceContext.newTimer(
+                  "Loading changes of project", Metadata.builder().projectName(key.get()).build());
           ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
diff --git a/java/com/google/gerrit/server/git/SystemReaderInstaller.java b/java/com/google/gerrit/server/git/SystemReaderInstaller.java
new file mode 100644
index 0000000..b6cc108
--- /dev/null
+++ b/java/com/google/gerrit/server/git/SystemReaderInstaller.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.git.DelegateSystemReader;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+@Singleton
+public class SystemReaderInstaller implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SitePaths site;
+
+  @Inject
+  SystemReaderInstaller(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public void start() {
+    SystemReader.setInstance(customReader());
+    logger.atInfo().log("Set JGit's SystemReader to read system config from %s", site.jgit_config);
+  }
+
+  @Override
+  public void stop() {}
+
+  private SystemReader customReader() {
+    SystemReader current = SystemReader.getInstance();
+
+    return new DelegateSystemReader(current) {
+      @Override
+      public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+        return new FileBasedConfig(parent, site.jgit_config.toFile(), FS.DETECTED);
+      }
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index 535644d..daf5ea5 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 57637c89..43483bf 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -18,9 +18,9 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
@@ -232,7 +232,7 @@
                     new Tag(
                         idConverter.fromByteString(t.getId()),
                         BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
-    return new TagSet(new Project.NameKey(proto.getProjectName()), refs, tags);
+    return new TagSet(Project.nameKey(proto.getProjectName()), refs, tags);
   }
 
   TagSetProto toProto() {
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 194283e..a574308 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -17,8 +17,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.util.Collection;
@@ -117,7 +117,7 @@
     @Override
     public TagSetHolder deserialize(byte[] in) {
       TagSetHolderProto proto = Protos.parseUnchecked(TagSetHolderProto.parser(), in);
-      TagSetHolder holder = new TagSetHolder(new Project.NameKey(proto.getProjectName()));
+      TagSetHolder holder = new TagSetHolder(Project.nameKey(proto.getProjectName()));
       if (proto.hasTags()) {
         holder.tags = TagSet.fromProto(proto.getTags());
       }
diff --git a/java/com/google/gerrit/server/git/TracingHook.java b/java/com/google/gerrit/server/git/TracingHook.java
new file mode 100644
index 0000000..56eded0
--- /dev/null
+++ b/java/com/google/gerrit/server/git/TracingHook.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.server.logging.TraceContext;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.transport.FetchV2Request;
+import org.eclipse.jgit.transport.LsRefsV2Request;
+import org.eclipse.jgit.transport.ProtocolV2Hook;
+
+/**
+ * Git hook for ls-refs and fetch that enables Gerrit request tracing if the user sets the 'trace'
+ * server option.
+ *
+ * <p>This hook is only invoked if Git protocol v2 is used.
+ *
+ * <p>If the 'trace' server option is specified without value, this means without providing a trace
+ * ID, a trace ID is generated, but it's not returned to the client. Hence users are advised to
+ * always provide a trace ID.
+ */
+public class TracingHook implements ProtocolV2Hook, AutoCloseable {
+  private TraceContext traceContext;
+
+  @Override
+  public void onLsRefs(LsRefsV2Request req) {
+    maybeStartTrace(req.getServerOptions());
+  }
+
+  @Override
+  public void onFetch(FetchV2Request req) {
+    maybeStartTrace(req.getServerOptions());
+  }
+
+  @Override
+  public void close() {
+    if (traceContext != null) {
+      traceContext.close();
+    }
+  }
+
+  /**
+   * Starts request tracing if 'trace' server option is set.
+   *
+   * @param serverOptionList list of provided server options
+   */
+  private void maybeStartTrace(List<String> serverOptionList) {
+    if (traceContext != null) {
+      // Trace was already started
+      return;
+    }
+
+    Optional<String> traceOption = parseTraceOption(serverOptionList);
+    traceContext =
+        TraceContext.newTrace(
+            traceOption.isPresent(),
+            traceOption.orElse(null),
+            (tagName, traceId) -> {
+              // TODO(ekempin): Return trace ID to client
+            });
+  }
+
+  private Optional<String> parseTraceOption(List<String> serverOptionList) {
+    if (serverOptionList == null || serverOptionList.isEmpty()) {
+      return Optional.empty();
+    }
+
+    Optional<String> traceOption =
+        serverOptionList.stream().filter(o -> o.startsWith("trace")).findAny();
+    if (!traceOption.isPresent()) {
+      return Optional.empty();
+    }
+
+    int e = traceOption.get().indexOf('=');
+    if (e > 0) {
+      // trace option was specified with trace ID: "--trace=<trace-ID>"
+      return Optional.of(traceOption.get().substring(e + 1));
+    }
+
+    // trace option was specified without trace ID: "--trace",
+    // return an empty string so that a trace ID is generated
+    return Optional.of("");
+  }
+}
diff --git a/java/com/google/gerrit/server/git/UploadPackInitializer.java b/java/com/google/gerrit/server/git/UploadPackInitializer.java
index b63c5b3..7e97d87 100644
--- a/java/com/google/gerrit/server/git/UploadPackInitializer.java
+++ b/java/com/google/gerrit/server/git/UploadPackInitializer.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Project;
 import org.eclipse.jgit.transport.UploadPack;
 
 @ExtensionPoint
diff --git a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
index aa02fba..4afff2b 100644
--- a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
+++ b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.storage.pack.PackStatistics;
@@ -43,14 +44,15 @@
 
   @Inject
   UploadPackMetricsHook(MetricMaker metricMaker) {
-    Field<Operation> operation = Field.ofEnum(Operation.class, "operation");
+    Field<Operation> operationField =
+        Field.ofEnum(Operation.class, "operation", Metadata.Builder::gitOperation).build();
     requestCount =
         metricMaker.newCounter(
             "git/upload-pack/request_count",
             new Description("Total number of git-upload-pack requests")
                 .setRate()
                 .setUnit("requests"),
-            operation);
+            operationField);
 
     counting =
         metricMaker.newTimer(
@@ -58,7 +60,7 @@
             new Description("Time spent in the 'Counting...' phase")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     compressing =
         metricMaker.newTimer(
@@ -66,7 +68,7 @@
             new Description("Time spent in the 'Compressing...' phase")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     writing =
         metricMaker.newTimer(
@@ -74,7 +76,7 @@
             new Description("Time spent transferring bytes to client")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     packBytes =
         metricMaker.newHistogram(
@@ -82,7 +84,7 @@
             new Description("Distribution of sizes of packs sent to clients")
                 .setCumulative()
                 .setUnit(Units.BYTES),
-            operation);
+            operationField);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/git/UsersSelfAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/UsersSelfAdvertiseRefsHook.java
new file mode 100644
index 0000000..6c1879e
--- /dev/null
+++ b/java/com/google/gerrit/server/git/UsersSelfAdvertiseRefsHook.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.SymbolicRef;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+
+/**
+ * Advertises {@code refs/users/self} for authenticated users when interacting with the {@code
+ * All-Users} repository.
+ */
+@Singleton
+public class UsersSelfAdvertiseRefsHook implements AdvertiseRefsHook {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<CurrentUser> userProvider;
+
+  @Inject
+  public UsersSelfAdvertiseRefsHook(Provider<CurrentUser> userProvider) {
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  public void advertiseRefs(UploadPack uploadPack) throws ServiceMayNotContinueException {
+    CurrentUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      return;
+    }
+
+    addSelfSymlinkIfNecessary(
+        uploadPack.getRepository().getRefDatabase(),
+        HookUtil.ensureAllRefsAdvertised(uploadPack),
+        user.getAccountId());
+  }
+
+  @Override
+  public void advertiseRefs(ReceivePack receivePack) throws ServiceMayNotContinueException {
+    CurrentUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      return;
+    }
+
+    addSelfSymlinkIfNecessary(
+        receivePack.getRepository().getRefDatabase(),
+        HookUtil.ensureAllRefsAdvertised(receivePack),
+        user.getAccountId());
+  }
+
+  private static void addSelfSymlinkIfNecessary(
+      RefDatabase refDatabase, Map<String, Ref> advertisedRefs, Account.Id accountId)
+      throws ServiceMayNotContinueException {
+    String refName = RefNames.refsUsers(accountId);
+    try {
+      Ref r = refDatabase.exactRef(refName);
+      if (r == null) {
+        logger.atWarning().log("User ref %s not found", refName);
+        return;
+      }
+
+      SymbolicRef s = new SymbolicRef(RefNames.REFS_USERS_SELF, r);
+      advertisedRefs.put(s.getName(), s);
+      logger.atFinest().log("Added %s as alias for user ref %s", RefNames.REFS_USERS_SELF, refName);
+    } catch (IOException e) {
+      throw new ServiceMayNotContinueException(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 4522b0d..f2a0ff1 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,11 +18,11 @@
 
 import com.google.common.base.CaseFormat;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.logging.LoggingContext;
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index 97beefd..e90f58b 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git.meta;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 8694299..8ab2779 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -19,8 +19,11 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.util.CommitMessageUtil;
@@ -114,7 +117,7 @@
   /** @return revision of the metadata that was loaded. */
   @Nullable
   public ObjectId getRevision() {
-    return revision != null ? revision.copy() : null;
+    return ObjectIds.copyOrNull(revision);
   }
 
   /**
@@ -433,18 +436,18 @@
           case REJECTED_MISSING_OBJECT:
           case REJECTED_OTHER_REASON:
           default:
-            throw new IOException(errorMsg(ru, db.getDirectory()));
+            throw new GitUpdateFailureException(errorMsg(ru, db.getDirectory()), ru);
         }
       }
-
-      private String errorMsg(RefUpdate ru, File location) {
-        return String.format(
-            "Cannot update %s in %s: %s (%s)",
-            ru.getName(), location, ru.getResult(), ru.getRefLogMessage());
-      }
     };
   }
 
+  private String errorMsg(RefUpdate ru, File location) {
+    return String.format(
+        "Cannot update %s in %s: %s (%s)",
+        ru.getName(), location, ru.getResult(), ru.getRefLogMessage());
+  }
+
   protected DirCache readTree(RevTree tree)
       throws IOException, MissingObjectException, IncorrectObjectTypeException {
     DirCache dc = DirCache.newInCore();
@@ -494,8 +497,13 @@
 
     try (TraceTimer timer =
             TraceContext.newTimer(
-                "Read file '%s' from ref '%s' of project '%s' from revision '%s'",
-                fileName, getRefName(), projectName, revision.name());
+                "Read file",
+                Metadata.builder()
+                    .projectName(projectName.get())
+                    .noteDbRefName(getRefName())
+                    .revision(revision.name())
+                    .noteDbFilePath(fileName)
+                    .build());
         TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
       if (tw != null) {
         ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
@@ -570,7 +578,12 @@
   protected void saveFile(String fileName, byte[] raw) throws IOException {
     try (TraceTimer timer =
         TraceContext.newTimer(
-            "Save file '%s' in ref '%s' of project '%s'", fileName, getRefName(), projectName)) {
+            "Save file",
+            Metadata.builder()
+                .projectName(projectName.get())
+                .noteDbRefName(getRefName())
+                .noteDbFilePath(fileName)
+                .build())) {
       DirCacheEditor editor = newTree.editor();
       if (raw != null && 0 < raw.length) {
         final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
diff --git a/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java b/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
index c092c43..13ae54a 100644
--- a/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
+++ b/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
@@ -20,7 +20,7 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
@@ -34,7 +34,7 @@
   private Map<String, Ref> allRefs;
 
   @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+  public void advertiseRefs(ReceivePack rp) throws ServiceMayNotContinueException {
     allRefs = HookUtil.ensureAllRefsAdvertised(rp);
   }
 
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index da2887f..68d2010 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Counter0;
@@ -30,18 +32,18 @@
 import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ReceiveCommitsExecutor;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.PermissionAwareRepositoryManager;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UsersSelfAdvertiseRefsHook;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
@@ -62,7 +64,6 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -70,8 +71,6 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
 import org.eclipse.jgit.transport.PreReceiveHook;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceiveCommand.Result;
@@ -203,11 +202,20 @@
 
     @Inject
     Metrics(MetricMaker metricMaker) {
+      // For the changes metric the push type field is never set to PushType.NORMAL, hence it is not
+      // mentioned in the field description.
       changes =
           metricMaker.newHistogram(
               "receivecommits/changes_per_push",
               new Description("number of changes uploaded in a single push.").setCumulative(),
-              Field.ofEnum(PushType.class, "type", "type of push (create/replace, autoclose)"));
+              Field.ofEnum(PushType.class, "type", Metadata.Builder::pushType)
+                  .description("type of push (create/replace, autoclose)")
+                  .build());
+
+      Field<PushType> pushTypeField =
+          Field.ofEnum(PushType.class, "type", Metadata.Builder::pushType)
+              .description("type of push (create/replace, autoclose, normal)")
+              .build();
 
       latencyPerChange =
           metricMaker.newTimer(
@@ -217,7 +225,7 @@
                           + "(Only includes pushes which contain changes.)")
                   .setUnit(Units.MILLISECONDS)
                   .setCumulative(),
-              Field.ofEnum(PushType.class, "type", "type of push (create/replace, autoclose)"));
+              pushTypeField);
 
       latencyPerPush =
           metricMaker.newTimer(
@@ -225,8 +233,7 @@
               new Description("processing delay for a processing single push")
                   .setUnit(Units.MILLISECONDS)
                   .setCumulative(),
-              Field.ofEnum(
-                  PushType.class, "type", "type of push (create/replace, autoclose, normal)"));
+              pushTypeField);
 
       timeouts =
           metricMaker.newCounter(
@@ -262,6 +269,8 @@
       ContributorAgreementsChecker contributorAgreements,
       Metrics metrics,
       QuotaBackend quotaBackend,
+      UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook,
+      AllUsersName allUsersName,
       @Named(TIMEOUT_NAME) long timeoutMillis,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
@@ -277,9 +286,12 @@
     this.user = user;
     this.repo = repo;
     this.metrics = metrics;
-
+    // If the user lacks READ permission, some references may be filtered and hidden from view.
+    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
     Project.NameKey projectName = projectState.getNameKey();
-    receivePack = new ReceivePack(repo);
+    this.perm = permissionBackend.user(user).project(projectName);
+
+    receivePack = new ReceivePack(PermissionAwareRepositoryManager.wrap(repo, perm));
     receivePack.setAllowCreates(true);
     receivePack.setAllowDeletes(true);
     receivePack.setAllowNonFastForwards(true);
@@ -292,9 +304,6 @@
     receivePack.setPreReceiveHook(this);
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
-    // If the user lacks READ permission, some references may be filtered and hidden from view.
-    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
-    this.perm = permissionBackend.user(user).project(projectName);
     try {
       projectState.checkStatePermitsRead();
       this.perm.check(ProjectPermission.READ);
@@ -303,19 +312,14 @@
           receiveConfig.checkReferencedObjectsAreReachable);
     }
 
-    List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
     allRefsWatcher = new AllRefsWatcher();
-    advHooks.add(allRefsWatcher);
-    advHooks.add(
-        new DefaultAdvertiseRefsHook(perm, RefFilterOptions.builder().setFilterMeta(true).build()));
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
-    advHooks.add(new HackPushNegotiateHook());
-    receivePack.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
-
+    receivePack.setAdvertiseRefsHook(
+        ReceiveCommitsAdvertiseRefsHookChain.create(
+            allRefsWatcher, usersSelfAdvertiseRefsHook, allUsersName, queryProvider, projectName));
     resultChangeIds = new ResultChangeIds();
     receiveCommits =
         factory.create(
-            projectState, user, receivePack, allRefsWatcher, messageSender, resultChangeIds);
+            projectState, user, receivePack, repo, allRefsWatcher, messageSender, resultChangeIds);
     receiveCommits.init();
     QuotaResponse.Aggregated availableTokens =
         quotaBackend.user(user).project(projectName).availableTokens(REPOSITORY_SIZE_GROUP);
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index b1bf933..d89bb63 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -7,21 +7,22 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index a2d8e94..7b5f90bd 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -14,27 +14,29 @@
 
 package com.google.gerrit.server.git.receive;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -48,12 +50,29 @@
   private final IdentifiedUser user;
   private final PermissionBackend.ForProject permissions;
   private final Project project;
-  private final Branch.NameKey branch;
+  private final BranchNameKey branch;
   private final SshInfo sshInfo;
 
   interface Factory {
     BranchCommitValidator create(
-        ProjectState projectState, Branch.NameKey branch, IdentifiedUser user);
+        ProjectState projectState, BranchNameKey branch, IdentifiedUser user);
+  }
+
+  /** A boolean validation status and a list of additional messages. */
+  @AutoValue
+  abstract static class Result {
+    static Result create(boolean isValid, ImmutableList<CommitValidationMessage> messages) {
+      return new AutoValue_BranchCommitValidator_Result(isValid, messages);
+    }
+
+    /** Whether the commit is valid. */
+    abstract boolean isValid();
+
+    /**
+     * A list of messages related to the validation. Messages may be present regardless of the
+     * {@link #isValid()} status.
+     */
+    abstract ImmutableList<CommitValidationMessage> messages();
   }
 
   @Inject
@@ -62,7 +81,7 @@
       PermissionBackend permissionBackend,
       SshInfo sshInfo,
       @Assisted ProjectState projectState,
-      @Assisted Branch.NameKey branch,
+      @Assisted BranchNameKey branch,
       @Assisted IdentifiedUser user) {
     this.sshInfo = sshInfo;
     this.user = user;
@@ -80,17 +99,17 @@
    * @param commit the commit being validated.
    * @param isMerged whether this is a merge commit created by magicBranch --merge option
    * @param change the change for which this is a new patchset.
+   * @return The validation {@link Result}.
    */
-  public boolean validCommit(
+  Result validateCommit(
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
       boolean isMerged,
-      List<ValidationMessage> messages,
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
-    return validCommit(objectReader, cmd, commit, isMerged, messages, rejectCommits, change, false);
+    return validateCommit(objectReader, cmd, commit, isMerged, rejectCommits, change, false);
   }
 
   /**
@@ -102,55 +121,63 @@
    * @param isMerged whether this is a merge commit created by magicBranch --merge option
    * @param change the change for which this is a new patchset.
    * @param skipValidation whether 'skip-validation' was requested.
+   * @return The validation {@link Result}.
    */
-  public boolean validCommit(
+  Result validateCommit(
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
       boolean isMerged,
-      List<ValidationMessage> messages,
       NoteMap rejectCommits,
       @Nullable Change change,
       boolean skipValidation)
       throws IOException {
-    try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), objectReader, commit, user)) {
-      CommitValidators validators;
-      if (isMerged) {
-        validators =
-            commitValidatorsFactory.forMergedCommits(permissions, branch, user.asIdentifiedUser());
-      } else {
-        validators =
-            commitValidatorsFactory.forReceiveCommits(
-                permissions,
-                branch,
-                user.asIdentifiedUser(),
-                sshInfo,
-                rejectCommits,
-                receiveEvent.revWalk,
-                change,
-                skipValidation);
-      }
+    try (TraceTimer traceTimer = TraceContext.newTimer("BranchCommitValidator#validateCommit")) {
+      ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
+      try (CommitReceivedEvent receiveEvent =
+          new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
+        CommitValidators validators;
+        if (isMerged) {
+          validators =
+              commitValidatorsFactory.forMergedCommits(
+                  permissions, branch, user.asIdentifiedUser());
+        } else {
+          validators =
+              commitValidatorsFactory.forReceiveCommits(
+                  permissions,
+                  branch,
+                  user.asIdentifiedUser(),
+                  sshInfo,
+                  rejectCommits,
+                  receiveEvent.revWalk,
+                  change,
+                  skipValidation);
+        }
 
-      for (CommitValidationMessage m : validators.validate(receiveEvent)) {
-        messages.add(
-            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.getType()));
+        for (CommitValidationMessage m : validators.validate(receiveEvent)) {
+          messages.add(
+              new CommitValidationMessage(
+                  messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
+        }
+      } catch (CommitValidationException e) {
+        logger.atFine().log("Commit validation failed on %s", commit.name());
+        for (CommitValidationMessage m : e.getMessages()) {
+          // The non-error messages may contain background explanation for the
+          // fatal error, so have to preserve all messages.
+          messages.add(
+              new CommitValidationMessage(
+                  messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
+        }
+        cmd.setResult(
+            REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage(), objectReader));
+        return Result.create(false, messages.build());
       }
-    } catch (CommitValidationException e) {
-      logger.atFine().log("Commit validation failed on %s", commit.name());
-      for (CommitValidationMessage m : e.getMessages()) {
-        // The non-error messages may contain background explanation for the
-        // fatal error, so have to preserve all messages.
-        messages.add(
-            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.getType()));
-      }
-      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage()));
-      return false;
+      return Result.create(true, messages.build());
     }
-    return true;
   }
 
-  private String messageForCommit(RevCommit c, String msg) {
-    return String.format("commit %s: %s", c.abbreviate(RevId.ABBREV_LEN).name(), msg);
+  private String messageForCommit(RevCommit c, String msg, ObjectReader objectReader)
+      throws IOException {
+    return String.format("commit %s: %s", abbreviateName(c, objectReader), msg);
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
index 36d0eb7..72483af 100644
--- a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
+++ b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
@@ -18,18 +18,17 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
@@ -49,7 +48,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Size of an additional ".have" line. */
-  private static final int HAVE_LINE_LEN = 4 + Constants.OBJECT_ID_STRING_LENGTH + 1 + 5 + 1;
+  private static final int HAVE_LINE_LEN = 4 + ObjectIds.STR_LEN + 1 + 5 + 1;
 
   /**
    * Maximum number of bytes to "waste" in the advertisement with a peek at this repository's
@@ -73,9 +72,8 @@
     throw new UnsupportedOperationException("HackPushNegotiateHook cannot be used for UploadPack");
   }
 
-  @SuppressWarnings("deprecation")
   @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+  public void advertiseRefs(ReceivePack rp) throws ServiceMayNotContinueException {
     Map<String, Ref> r = rp.getAdvertisedRefs();
     if (r == null) {
       try {
@@ -91,38 +89,32 @@
     rp.setAdvertisedRefs(r, history(r.values(), rp));
   }
 
-  private Set<ObjectId> history(Collection<Ref> refs, BaseReceivePack rp) {
+  private Set<ObjectId> history(Collection<Ref> refs, ReceivePack rp) {
     Set<ObjectId> alreadySending = rp.getAdvertisedObjects();
-    if (alreadySending.isEmpty()) {
-      alreadySending = idsOf(refs);
-    }
-
-    int max = MAX_HISTORY - Math.max(0, alreadySending.size() - refs.size());
-    if (max <= 0) {
-      return Collections.emptySet();
+    if (MAX_HISTORY <= alreadySending.size()) {
+      return alreadySending;
     }
 
     // Scan history until the advertisement is full.
-    @SuppressWarnings("deprecation")
     RevWalk rw = rp.getRevWalk();
     rw.reset();
     try {
-      for (Ref ref : refs) {
+      Set<ObjectId> tips = idsOf(refs);
+      for (ObjectId tip : tips) {
         try {
-          if (ref.getObjectId() != null) {
-            rw.markStart(rw.parseCommit(ref.getObjectId()));
-          }
+          rw.markStart(rw.parseCommit(tip));
         } catch (IOException badCommit) {
           continue;
         }
       }
 
-      Set<ObjectId> history = Sets.newHashSetWithExpectedSize(max);
+      Set<ObjectId> history = Sets.newHashSetWithExpectedSize(MAX_HISTORY);
+      history.addAll(alreadySending);
       try {
         int stepCnt = 0;
-        for (RevCommit c; history.size() < max && (c = rw.next()) != null; ) {
+        for (RevCommit c; history.size() < MAX_HISTORY && (c = rw.next()) != null; ) {
           if (c.getParentCount() <= 1
-              && !alreadySending.contains(c)
+              && !tips.contains(c)
               && (history.size() < BASE_COMMITS || (++stepCnt % STEP_COMMITS) == 0)) {
             history.add(c);
           }
diff --git a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
index 17d4a38..a19dbac 100644
--- a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.quota.QuotaBackend;
@@ -61,7 +61,7 @@
   @Override
   public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
     hooks.runEach(h -> h.onPostReceive(rp, commands));
-    if (affectsSize(rp, commands)) {
+    if (affectsSize(rp)) {
       QuotaResponse.Aggregated a =
           quotaBackend
               .user(user)
@@ -78,21 +78,7 @@
     }
   }
 
-  public static boolean affectsSize(ReceivePack rp, Collection<ReceiveCommand> commands) {
-    long packSize;
-    try {
-      packSize = rp.getPackSize();
-    } catch (IllegalStateException e) {
-      // No pack was received, i.e. ref deletion or wind back
-      return false;
-    }
-    if (packSize > 0L) {
-      for (ReceiveCommand cmd : commands) {
-        if (cmd.getType() != ReceiveCommand.Type.DELETE) {
-          return true;
-        }
-      }
-    }
-    return false;
+  public static boolean affectsSize(ReceivePack rp) {
+    return rp.hasReceivedPack() && rp.getPackSize() > 0L;
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index d78dc52..092342c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.git.receive;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
@@ -40,7 +40,6 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
@@ -48,6 +47,7 @@
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
@@ -62,37 +62,48 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -118,8 +129,12 @@
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
@@ -180,11 +195,12 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Queue;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
-import java.util.regex.Matcher;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -223,9 +239,6 @@
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String CODE_REVIEW_ERROR =
-      "You need 'Push' rights to upload code review requests.\n"
-          + "Verify that you are pushing to the right branch.";
   private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'";
   private static final String CANNOT_DELETE_CONFIG =
       "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
@@ -236,6 +249,7 @@
         ProjectState projectState,
         IdentifiedUser user,
         ReceivePack receivePack,
+        Repository repository,
         AllRefsWatcher allRefsWatcher,
         MessageSender messageSender,
         ResultChangeIds resultChangeIds);
@@ -276,16 +290,14 @@
     }
   }
 
-  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
-      input -> {
-        if (input instanceof RestApiException) {
-          return (RestApiException) input;
-        } else if ((input instanceof ExecutionException)
-            && (input.getCause() instanceof RestApiException)) {
-          return (RestApiException) input.getCause();
-        }
-        return new RestApiException("Error inserting change/patchset", input);
-      };
+  private static RestApiException asRestApiException(Exception e) {
+    if (e instanceof RestApiException) {
+      return (RestApiException) e;
+    } else if ((e instanceof ExecutionException) && (e.getCause() instanceof RestApiException)) {
+      return (RestApiException) e.getCause();
+    }
+    return new RestApiException("Error inserting change/patchset", e);
+  }
 
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
   // somewhat, and kept sorted lexicographically within sections, except where later assignments
@@ -301,7 +313,10 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeReportFormatter changeFormatter;
   private final CmdLineParser.Factory optionParserFactory;
+  private final CommentsUtil commentsUtil;
+  private final PluginSetContext<CommentValidator> commentValidators;
   private final BranchCommitValidator.Factory commitValidatorFactory;
+  private final Config config;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -309,6 +324,7 @@
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -317,6 +333,7 @@
   private final ReceiveConfig receiveConfig;
   private final RefOperationValidators.Factory refValidatorsFactory;
   private final ReplaceOp.Factory replaceOpFactory;
+  private final PluginSetContext<RequestListener> requestListeners;
   private final RetryHelper retryHelper;
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
@@ -334,7 +351,6 @@
 
   // Immutable fields derived from constructor arguments.
   private final boolean allowProjectOwnersToChangeParent;
-  private final boolean allowPushToRefsChanges;
   private final LabelTypes labelTypes;
   private final NoteMap rejectCommits;
   private final PermissionBackend.ForProject permissions;
@@ -343,7 +359,7 @@
 
   // Collections populated during processing.
   private final List<UpdateGroupsRequest> updateGroups;
-  private final List<ValidationMessage> messages;
+  private final Queue<ValidationMessage> messages;
   /** Multimap of error text to refnames that produced that error. */
   private final ListMultimap<String, String> errors;
 
@@ -363,6 +379,7 @@
 
   private MessageSender messageSender;
   private ResultChangeIds resultChangeIds;
+  private ImmutableMap<String, String> loggingTags;
 
   @Inject
   ReceiveCommits(
@@ -370,21 +387,24 @@
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      @GerritServerConfig Config cfg,
+      @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
       ChangeIndexer indexer,
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
       CmdLineParser.Factory optionParserFactory,
+      CommentsUtil commentsUtil,
       BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginSetContext<ReceivePackInitializer> initializers,
+      PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
+      DynamicSet<PerformanceLogger> performanceLoggers,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<InternalChangeQuery> queryProvider,
@@ -393,6 +413,7 @@
       ReceiveConfig receiveConfig,
       RefOperationValidators.Factory refValidatorsFactory,
       ReplaceOp.Factory replaceOpFactory,
+      PluginSetContext<RequestListener> requestListeners,
       RetryHelper retryHelper,
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
@@ -403,6 +424,7 @@
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
+      @Assisted Repository repository,
       @Assisted AllRefsWatcher allRefsWatcher,
       @Nullable @Assisted MessageSender messageSender,
       @Assisted ResultChangeIds resultChangeIds)
@@ -413,7 +435,10 @@
     this.batchUpdateFactory = batchUpdateFactory;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeInserterFactory = changeInserterFactory;
+    this.commentsUtil = commentsUtil;
+    this.commentValidators = commentValidators;
     this.commitValidatorFactory = commitValidatorFactory;
+    this.config = config;
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.editUtil = editUtil;
@@ -430,10 +455,12 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
+    this.performanceLoggers = performanceLoggers;
     this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
     this.replaceOpFactory = replaceOpFactory;
+    this.requestListeners = requestListeners;
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
@@ -447,24 +474,25 @@
     this.projectState = projectState;
     this.user = user;
     this.receivePack = rp;
+    // This repository instance in unwrapped, while the repository instance in
+    // receivePack.getRepo() is wrapped in PermissionAwareRepository instance.
+    this.repo = repository;
 
     // Immutable fields derived from constructor arguments.
-    allowPushToRefsChanges = cfg.getBoolean("receive", "allowPushToRefsChanges", false);
-    repo = rp.getRepository();
     project = projectState.getProject();
     labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
-    rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
+    rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk());
 
     // Collections populated during processing.
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
-    messages = new ArrayList<>();
+    messages = new ConcurrentLinkedQueue<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
     updateGroups = new ArrayList<>();
 
     this.allowProjectOwnersToChangeParent =
-        cfg.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
 
     // Other settings populated during processing.
     newChangeForAllNotInTarget =
@@ -473,6 +501,7 @@
     // Handles for outputting back over the wire to the end user.
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
     this.resultChangeIds = resultChangeIds;
+    this.loggingTags = ImmutableMap.of();
   }
 
   void init() {
@@ -499,127 +528,150 @@
     addMessage(error, ValidationMessage.Type.ERROR);
   }
 
+  /**
+   * Sends all messages which have been collected while processing the push to the client.
+   *
+   * <p><strong>Attention:</strong>{@link AsyncReceiveCommits} may call this method while {@link
+   * #processCommands(Collection, MultiProgressMonitor)} is still running (if the execution of
+   * processCommands takes too long and AsyncReceiveCommits gets a timeout). This means that local
+   * variables that are accessed in this method must be thread-safe (otherwise we may hit a {@link
+   * java.util.ConcurrentModificationException} if we read a variable here that at the same time is
+   * updated by the background thread that still executes processCommands).
+   */
   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);
+      }
     }
   }
 
   void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
-    commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-    processCommandsUnsafe(commands, progress);
-    rejectRemaining(commands, INTERNAL_SERVER_ERROR);
-
-    // This sends error messages before the 'done' string of the progress monitor is sent.
-    // Currently, the test framework relies on this ordering to understand if pushes completed
-    // successfully.
-    sendErrorMessages();
-
-    commandProgress.end();
-    progress.end();
-  }
-
-  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
-  private void processCommandsUnsafe(
-      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     parsePushOptions();
+    int commandCount = commands.size();
     try (TraceContext traceContext =
-        TraceContext.newTrace(
-            tracePushOption.isPresent(),
-            tracePushOption.orElse(null),
-            (tagName, traceId) -> addMessage(tagName + ": " + traceId))) {
+            TraceContext.newTrace(
+                tracePushOption.isPresent(),
+                tracePushOption.orElse(null),
+                (tagName, traceId) -> addMessage(tagName + ": " + traceId));
+        TraceTimer traceTimer =
+            newTimer("processCommands", Metadata.builder().resourceCount(commandCount));
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(config, performanceLoggers)) {
+      RequestInfo requestInfo =
+          RequestInfo.builder(RequestInfo.RequestType.GIT_RECEIVE, user, traceContext)
+              .project(project.getNameKey())
+              .build();
+      requestListeners.runEach(l -> l.onRequest(requestInfo));
       traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
 
-      logger.atFinest().log("Calling user: %s", user.getLoggableName());
-
       // Log the push options here, rather than in parsePushOptions(), so that they are included
       // into the trace if tracing is enabled.
       logger.atFine().log("push options: %s", receivePack.getPushOptions());
 
-      if (!projectState.getProject().getState().permitsWrite()) {
-        for (ReceiveCommand cmd : commands) {
-          reject(cmd, "prohibited by Gerrit: project state does not permit write");
-        }
-        return;
-      }
+      Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+      commands =
+          commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+      processCommandsUnsafe(commands, progress);
+      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
 
-      logger.atFine().log("Parsing %d commands", commands.size());
+      // This sends error messages before the 'done' string of the progress monitor is sent.
+      // Currently, the test framework relies on this ordering to understand if pushes completed
+      // successfully.
+      sendErrorMessages();
 
-      List<ReceiveCommand> magicCommands = new ArrayList<>();
-      List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
-      List<ReceiveCommand> regularCommands = new ArrayList<>();
-
-      for (ReceiveCommand cmd : commands) {
-        if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-          magicCommands.add(cmd);
-        } else if (isDirectChangesPush(cmd.getRefName())) {
-          directPatchSetPushCommands.add(cmd);
-        } else {
-          regularCommands.add(cmd);
-        }
-      }
-
-      int commandTypes =
-          (magicCommands.isEmpty() ? 0 : 1)
-              + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
-              + (regularCommands.isEmpty() ? 0 : 1);
-
-      if (commandTypes > 1) {
-        rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
-        return;
-      }
-
-      try {
-        if (!regularCommands.isEmpty()) {
-          handleRegularCommands(regularCommands, progress);
-          return;
-        }
-
-        for (ReceiveCommand cmd : directPatchSetPushCommands) {
-          parseDirectChangesPush(cmd);
-        }
-
-        boolean first = true;
-        for (ReceiveCommand cmd : magicCommands) {
-          if (first) {
-            parseMagicBranch(cmd);
-            first = false;
-          } else {
-            reject(cmd, "duplicate request");
-          }
-        }
-      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
-        return;
-      }
-
-      Task newProgress = progress.beginSubTask("new", UNKNOWN);
-      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-
-      List<CreateRequest> newChanges = Collections.emptyList();
-      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-        newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
-      }
-
-      // Commit validation has already happened, so any changes without Change-Id are for the
-      // deprecated feature.
-      warnAboutMissingChangeId(newChanges);
-      preparePatchSetsForReplace(newChanges);
-      insertChangesAndPatchSets(newChanges, replaceProgress);
-      newProgress.end();
-      replaceProgress.end();
-      queueSuccessMessages(newChanges);
-
-      logger.atFine().log(
-          "Command results: %s",
-          lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+      commandProgress.end();
+      progress.end();
+      loggingTags = traceContext.getTags();
+      logger.atFine().log("Processing commands done.");
     }
   }
 
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    logger.atFine().log("Calling user: %s", user.getLoggableName());
+    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+
+    if (!projectState.getProject().getState().permitsWrite()) {
+      for (ReceiveCommand cmd : commands) {
+        reject(cmd, "prohibited by Gerrit: project state does not permit write");
+      }
+      return;
+    }
+
+    logger.atFine().log("Parsing %d commands", commands.size());
+
+    List<ReceiveCommand> magicCommands = new ArrayList<>();
+    List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+    for (ReceiveCommand cmd : commands) {
+      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+        magicCommands.add(cmd);
+      } else {
+        regularCommands.add(cmd);
+      }
+    }
+
+    int commandTypes = (magicCommands.isEmpty() ? 0 : 1) + (regularCommands.isEmpty() ? 0 : 1);
+
+    if (commandTypes > 1) {
+      rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+      return;
+    }
+
+    try {
+      if (!regularCommands.isEmpty()) {
+        handleRegularCommands(regularCommands, progress);
+        return;
+      }
+
+      boolean first = true;
+      for (ReceiveCommand cmd : magicCommands) {
+        if (first) {
+          parseMagicBranch(cmd);
+          first = false;
+        } else {
+          reject(cmd, "duplicate request");
+        }
+      }
+    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+      logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+      return;
+    }
+
+    Task newProgress = progress.beginSubTask("new", UNKNOWN);
+    Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+    List<CreateRequest> newChanges = Collections.emptyList();
+    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+      newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+    }
+
+    // Commit validation has already happened, so any changes without Change-Id are for the
+    // deprecated feature.
+    warnAboutMissingChangeId(newChanges);
+    preparePatchSetsForReplace(newChanges);
+    insertChangesAndPatchSets(newChanges, replaceProgress);
+    newProgress.end();
+    replaceProgress.end();
+    queueSuccessMessages(newChanges);
+
+    logger.atFine().log(
+        "Command results: %s",
+        lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+  }
+
   private void sendErrorMessages() {
     if (!errors.isEmpty()) {
       logger.atFine().log("Handling error conditions: %s", errors.keySet());
@@ -633,69 +685,72 @@
 
   private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
       throws PermissionBackendException, IOException, NoSuchProjectException {
-    resultChangeIds.setMagicPush(false);
-    for (ReceiveCommand cmd : cmds) {
-      parseRegularCommand(cmd);
-    }
-
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo, rw, ins);
-      bu.setRefLogMessage("push");
-
-      int added = 0;
+    try (TraceTimer traceTimer =
+        newTimer("handleRegularCommands", Metadata.builder().resourceCount(cmds.size()))) {
+      resultChangeIds.setMagicPush(false);
       for (ReceiveCommand cmd : cmds) {
-        if (cmd.getResult() == NOT_ATTEMPTED) {
-          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
-          added++;
+        parseRegularCommand(cmd);
+      }
+
+      try (BatchUpdate bu =
+              batchUpdateFactory.create(
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter();
+          ObjectReader reader = ins.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo, rw, ins);
+        bu.setRefLogMessage("push");
+
+        int added = 0;
+        for (ReceiveCommand cmd : cmds) {
+          if (cmd.getResult() == NOT_ATTEMPTED) {
+            bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+            added++;
+          }
+        }
+        logger.atFine().log("Added %d additional ref updates", added);
+        bu.execute();
+      } catch (UpdateException | RestApiException e) {
+        rejectRemaining(cmds, INTERNAL_SERVER_ERROR);
+        logger.atSevere().withCause(e).log("update failed:");
+      }
+
+      Set<BranchNameKey> branches = new HashSet<>();
+      for (ReceiveCommand c : cmds) {
+        // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
+        // should happen in this loop are things that can't happen within one BatchUpdate because
+        // they involve kicking off an additional BatchUpdate.
+        if (c.getResult() != OK) {
+          continue;
+        }
+        if (isHead(c) || isConfig(c)) {
+          switch (c.getType()) {
+            case CREATE:
+            case UPDATE:
+            case UPDATE_NONFASTFORWARD:
+              Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+              autoCloseChanges(c, closeProgress);
+              closeProgress.end();
+              branches.add(BranchNameKey.create(project.getNameKey(), c.getRefName()));
+              break;
+
+            case DELETE:
+              break;
+          }
         }
       }
-      logger.atFine().log("Added %d additional ref updates", added);
-      bu.execute();
-    } catch (UpdateException | RestApiException e) {
-      rejectRemaining(cmds, INTERNAL_SERVER_ERROR);
-      logger.atSevere().withCause(e).log("update failed:");
-    }
 
-    Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : cmds) {
-      // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-      // should happen in this loop are things that can't happen within one BatchUpdate because
-      // they involve kicking off an additional BatchUpdate.
-      if (c.getResult() != OK) {
-        continue;
-      }
-      if (isHead(c) || isConfig(c)) {
-        switch (c.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
-            autoCloseChanges(c, closeProgress);
-            closeProgress.end();
-            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
-            break;
-
-          case DELETE:
-            break;
+      // Update superproject gitlinks if required.
+      if (!branches.isEmpty()) {
+        try (MergeOpRepoManager orm = ormProvider.get()) {
+          orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+          SubmoduleOp op = subOpFactory.create(branches, orm);
+          op.updateSuperProjects();
+        } catch (SubmoduleException e) {
+          logger.atSevere().withCause(e).log("Can't update the superprojects");
         }
       }
     }
-
-    // Update superproject gitlinks if required.
-    if (!branches.isEmpty()) {
-      try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
-        SubmoduleOp op = subOpFactory.create(branches, orm);
-        op.updateSuperProjects();
-      } catch (SubmoduleException e) {
-        logger.atSevere().withCause(e).log("Can't update the superprojects");
-      }
-    }
   }
 
   /** Appends messages for successful change creation/updates. */
@@ -755,7 +810,7 @@
     Boolean isPrivate = null;
     Boolean wip = null;
     if (!updated.isEmpty()) {
-      edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
+      edit = magicBranch != null && magicBranch.edit;
       if (magicBranch != null) {
         if (magicBranch.isPrivate) {
           isPrivate = true;
@@ -814,97 +869,102 @@
   }
 
   private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
-    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
-    if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-      logger.atWarning().log(
-          "Skipping change updates on %s because ref update failed: %s %s",
-          project.getName(),
-          magicBranchCmd.getResult(),
-          Strings.nullToEmpty(magicBranchCmd.getMessage()));
-      return;
-    }
-
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo, rw, ins);
-      bu.setRefLogMessage("push");
-      if (magicBranch != null) {
-        bu.setNotify(magicBranch.getNotifyForNewChange());
+    try (TraceTimer traceTimer =
+        newTimer(
+            "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
+      ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+      if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+        logger.atWarning().log(
+            "Skipping change updates on %s because ref update failed: %s %s",
+            project.getName(),
+            magicBranchCmd.getResult(),
+            Strings.nullToEmpty(magicBranchCmd.getMessage()));
+        return;
       }
 
-      logger.atFine().log("Adding %d replace requests", newChanges.size());
-      for (ReplaceRequest replace : replaceByChange.values()) {
+      try (BatchUpdate bu =
+              batchUpdateFactory.create(
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter();
+          ObjectReader reader = ins.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo, rw, ins);
+        bu.setRefLogMessage("push");
         if (magicBranch != null) {
-          bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
+          bu.setNotify(magicBranch.getNotifyForNewChange());
         }
-        replace.addOps(bu, replaceProgress);
-      }
 
-      logger.atFine().log("Adding %d create requests", newChanges.size());
-      for (CreateRequest create : newChanges) {
-        create.addOps(bu);
-      }
-
-      logger.atFine().log("Adding %d group update requests", newChanges.size());
-      updateGroups.forEach(r -> r.addOps(bu));
-
-      logger.atFine().log("Executing batch");
-      try {
-        bu.execute();
-      } catch (UpdateException e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-
-      replaceByChange.values().stream()
-          .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.REPLACED, req.ontoChange));
-      newChanges.stream()
-          .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.CREATED, req.changeId));
-
-      if (magicBranchCmd != null) {
-        magicBranchCmd.setResult(OK);
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage == null) {
-          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-            // Not necessarily the magic branch, so need to set OK on the original value.
-            replace.inputCommand.setResult(OK);
+        logger.atFine().log("Adding %d replace requests", newChanges.size());
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          if (magicBranch != null) {
+            bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
           }
-        } else {
-          logger.atFine().log("Rejecting due to message from ReplaceOp");
-          reject(replace.inputCommand, rejectMessage);
+          replace.addOps(bu, replaceProgress);
         }
-      }
 
-    } catch (ResourceConflictException e) {
-      addError(e.getMessage());
-      reject(magicBranchCmd, "conflict");
-    } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
-      logger.atFine().withCause(e).log("Rejecting due to client error");
-      reject(magicBranchCmd, e.getMessage());
-    } catch (RestApiException | IOException e) {
-      logger.atSevere().withCause(e).log("Can't insert change/patch set for %s", project.getName());
-      reject(magicBranchCmd, String.format("%s: %s", INTERNAL_SERVER_ERROR, e.getMessage()));
-    }
+        logger.atFine().log("Adding %d create requests", newChanges.size());
+        for (CreateRequest create : newChanges) {
+          create.addOps(bu);
+        }
 
-    if (magicBranch != null && magicBranch.submit) {
-      try {
-        submit(newChanges, replaceByChange.values());
+        logger.atFine().log("Adding %d group update requests", newChanges.size());
+        updateGroups.forEach(r -> r.addOps(bu));
+
+        logger.atFine().log("Executing batch");
+        try {
+          bu.execute();
+        } catch (UpdateException e) {
+          throw asRestApiException(e);
+        }
+
+        replaceByChange.values().stream()
+            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.REPLACED, req.ontoChange));
+        newChanges.stream()
+            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.CREATED, req.changeId));
+
+        if (magicBranchCmd != null) {
+          magicBranchCmd.setResult(OK);
+        }
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          String rejectMessage = replace.getRejectMessage();
+          if (rejectMessage == null) {
+            if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+              // Not necessarily the magic branch, so need to set OK on the original value.
+              replace.inputCommand.setResult(OK);
+            }
+          } else {
+            logger.atFine().log("Rejecting due to message from ReplaceOp");
+            reject(replace.inputCommand, rejectMessage);
+          }
+        }
+
       } catch (ResourceConflictException e) {
         addError(e.getMessage());
         reject(magicBranchCmd, "conflict");
-      } catch (RestApiException
-          | StorageException
-          | UpdateException
-          | IOException
-          | ConfigInvalidException
-          | PermissionBackendException e) {
-        logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
-        reject(magicBranchCmd, "error during submit");
+      } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
+        logger.atFine().withCause(e).log("Rejecting due to client error");
+        reject(magicBranchCmd, e.getMessage());
+      } catch (RestApiException | IOException e) {
+        logger.atSevere().withCause(e).log(
+            "Can't insert change/patch set for %s", project.getName());
+        reject(magicBranchCmd, String.format("%s: %s", INTERNAL_SERVER_ERROR, e.getMessage()));
+      }
+
+      if (magicBranch != null && magicBranch.submit) {
+        try {
+          submit(newChanges, replaceByChange.values());
+        } catch (ResourceConflictException e) {
+          addError(e.getMessage());
+          reject(magicBranchCmd, "conflict");
+        } catch (RestApiException
+            | StorageException
+            | UpdateException
+            | IOException
+            | ConfigInvalidException
+            | PermissionBackendException e) {
+          logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
+          reject(magicBranchCmd, "error during submit");
+        }
       }
     }
   }
@@ -938,7 +998,7 @@
     if (!noteDbValues.isEmpty()) {
       // These semantics for duplicates/errors are somewhat arbitrary and may not match e.g. the
       // CmdLineParser behavior used by MagicBranchInput.
-      String value = noteDbValues.get(noteDbValues.size() - 1);
+      String value = Iterables.getLast(noteDbValues);
       noteDbPushOption = NoteDbPushOption.parse(value);
       if (!noteDbPushOption.isPresent()) {
         addError("Invalid value in -o " + NoteDbPushOption.OPTION_NAME + "=" + value);
@@ -949,32 +1009,12 @@
 
     List<String> traceValues = pushOptions.get("trace");
     if (!traceValues.isEmpty()) {
-      String value = traceValues.get(traceValues.size() - 1);
-      tracePushOption = Optional.of(value);
+      tracePushOption = Optional.of(Iterables.getLast(traceValues));
     } else {
       tracePushOption = Optional.empty();
     }
   }
 
-  private static boolean isDirectChangesPush(String refname) {
-    Matcher m = NEW_PATCHSET_PATTERN.matcher(refname);
-    return m.matches();
-  }
-
-  private void parseDirectChangesPush(ReceiveCommand cmd) {
-    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
-    checkArgument(m.matches());
-
-    if (allowPushToRefsChanges) {
-      // The referenced change must exist and must still be open.
-      Change.Id changeId = Change.Id.parse(m.group(1));
-      parseReplaceCommand(cmd, changeId);
-      messages.add(new ValidationMessage("warning: pushes to refs/changes are deprecated", false));
-    } else {
-      reject(cmd, "upload to refs/changes not allowed");
-    }
-  }
-
   // Wrap ReceiveCommand so the progress counter works automatically.
   private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
     String refname = cmd.getRefName();
@@ -1007,161 +1047,166 @@
    */
   private void parseRegularCommand(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
-    if (cmd.getResult() != NOT_ATTEMPTED) {
-      // Already rejected by the core receive process.
-      logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
-      return;
-    }
-
-    if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
-      reject(cmd, "not valid ref");
-      return;
-    }
-    if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
-      // Reject pushes to NoteDb refs without a special option and permission. Note that this
-      // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
-      // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
-      // migration finishes.
-      logger.atFine().log(
-          "%s NoteDb ref %s with %s=%s",
-          cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
-      if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
-        // Only reject this command, not the whole push. This supports the use case of "git clone
-        // --mirror" followed by "git push --mirror", when the user doesn't really intend to clone
-        // or mirror the NoteDb data; there is no single refspec that describes all refs *except*
-        // NoteDb refs.
-        reject(
-            cmd,
-            "NoteDb update requires -o "
-                + NoteDbPushOption.OPTION_NAME
-                + "="
-                + NoteDbPushOption.ALLOW.value());
+    try (TraceTimer traceTimer = newTimer("parseRegularCommand")) {
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        // Already rejected by the core receive process.
+        logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
         return;
       }
-      try {
-        permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-      } catch (AuthException e) {
-        reject(cmd, "NoteDb update requires access database permission");
+
+      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
+        reject(cmd, "not valid ref");
         return;
       }
-    }
+      if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
+        // Reject pushes to NoteDb refs without a special option and permission. Note that this
+        // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
+        // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
+        // migration finishes.
+        logger.atFine().log(
+            "%s NoteDb ref %s with %s=%s",
+            cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
+        if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
+          // Only reject this command, not the whole push. This supports the use case of "git clone
+          // --mirror" followed by "git push --mirror", when the user doesn't really intend to clone
+          // or mirror the NoteDb data; there is no single refspec that describes all refs *except*
+          // NoteDb refs.
+          reject(
+              cmd,
+              "NoteDb update requires -o "
+                  + NoteDbPushOption.OPTION_NAME
+                  + "="
+                  + NoteDbPushOption.ALLOW.value());
+          return;
+        }
+        try {
+          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
+        } catch (AuthException e) {
+          reject(cmd, "NoteDb update requires access database permission");
+          return;
+        }
+      }
 
-    switch (cmd.getType()) {
-      case CREATE:
-        parseCreate(cmd);
-        break;
+      switch (cmd.getType()) {
+        case CREATE:
+          parseCreate(cmd);
+          break;
 
-      case UPDATE:
-        parseUpdate(cmd);
-        break;
+        case UPDATE:
+          parseUpdate(cmd);
+          break;
 
-      case DELETE:
-        parseDelete(cmd);
-        break;
+        case DELETE:
+          parseDelete(cmd);
+          break;
 
-      case UPDATE_NONFASTFORWARD:
-        parseRewind(cmd);
-        break;
+        case UPDATE_NONFASTFORWARD:
+          parseRewind(cmd);
+          break;
 
-      default:
-        reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+        default:
+          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+          return;
+      }
+
+      if (cmd.getResult() != NOT_ATTEMPTED) {
         return;
-    }
+      }
 
-    if (cmd.getResult() != NOT_ATTEMPTED) {
-      return;
-    }
-
-    if (isConfig(cmd)) {
-      validateConfigPush(cmd);
+      if (isConfig(cmd)) {
+        validateConfigPush(cmd);
+      }
     }
   }
 
   /** Validates a push to refs/meta/config, and reject the command if it fails. */
   private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
-    logger.atFine().log("Processing %s command", cmd.getRefName());
-    try {
-      permissions.check(ProjectPermission.WRITE_CONFIG);
-    } catch (AuthException e) {
-      reject(
-          cmd,
-          String.format(
-              "must be either project owner or have %s permission",
-              ProjectPermission.WRITE_CONFIG.describeForException()));
-      return;
-    }
+    try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
+      logger.atFine().log("Processing %s command", cmd.getRefName());
+      try {
+        permissions.check(ProjectPermission.WRITE_CONFIG);
+      } catch (AuthException e) {
+        reject(
+            cmd,
+            String.format(
+                "must be either project owner or have %s permission",
+                ProjectPermission.WRITE_CONFIG.describeForException()));
+        return;
+      }
 
-    switch (cmd.getType()) {
-      case CREATE:
-      case UPDATE:
-      case UPDATE_NONFASTFORWARD:
-        try {
-          ProjectConfig cfg = projectConfigFactory.create(project.getNameKey());
-          cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
-          if (!cfg.getValidationErrors().isEmpty()) {
-            addError("Invalid project configuration:");
-            for (ValidationError err : cfg.getValidationErrors()) {
-              addError("  " + err.getMessage());
+      switch (cmd.getType()) {
+        case CREATE:
+        case UPDATE:
+        case UPDATE_NONFASTFORWARD:
+          try {
+            ProjectConfig cfg = projectConfigFactory.create(project.getNameKey());
+            cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
+            if (!cfg.getValidationErrors().isEmpty()) {
+              addError("Invalid project configuration:");
+              for (ValidationError err : cfg.getValidationErrors()) {
+                addError("  " + err.getMessage());
+              }
+              reject(cmd, "invalid project configuration");
+              logger.atSevere().log(
+                  "User %s tried to push invalid project configuration %s for %s",
+                  user.getLoggableName(), cmd.getNewId().name(), project.getName());
+              return;
             }
+            Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+            Project.NameKey oldParent = project.getParent(allProjectsName);
+            if (oldParent == null) {
+              // update of the 'All-Projects' project
+              if (newParent != null) {
+                reject(cmd, "invalid project configuration: root project cannot have parent");
+                return;
+              }
+            } else {
+              if (!oldParent.equals(newParent)) {
+                if (allowProjectOwnersToChangeParent) {
+                  try {
+                    permissionBackend
+                        .user(user)
+                        .project(project.getNameKey())
+                        .check(ProjectPermission.WRITE_CONFIG);
+                  } catch (AuthException e) {
+                    reject(
+                        cmd, "invalid project configuration: only project owners can set parent");
+                    return;
+                  }
+                } else {
+                  try {
+                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                  } catch (AuthException e) {
+                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    return;
+                  }
+                }
+              }
+
+              if (projectCache.get(newParent) == null) {
+                reject(cmd, "invalid project configuration: parent does not exist");
+                return;
+              }
+            }
+            validatePluginConfig(cmd, cfg);
+          } catch (Exception e) {
             reject(cmd, "invalid project configuration");
-            logger.atSevere().log(
+            logger.atSevere().withCause(e).log(
                 "User %s tried to push invalid project configuration %s for %s",
                 user.getLoggableName(), cmd.getNewId().name(), project.getName());
             return;
           }
-          Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-          Project.NameKey oldParent = project.getParent(allProjectsName);
-          if (oldParent == null) {
-            // update of the 'All-Projects' project
-            if (newParent != null) {
-              reject(cmd, "invalid project configuration: root project cannot have parent");
-              return;
-            }
-          } else {
-            if (!oldParent.equals(newParent)) {
-              if (allowProjectOwnersToChangeParent) {
-                try {
-                  permissionBackend
-                      .user(user)
-                      .project(project.getNameKey())
-                      .check(ProjectPermission.WRITE_CONFIG);
-                } catch (AuthException e) {
-                  reject(cmd, "invalid project configuration: only project owners can set parent");
-                  return;
-                }
-              } else {
-                try {
-                  permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                } catch (AuthException e) {
-                  reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                  return;
-                }
-              }
-            }
+          break;
 
-            if (projectCache.get(newParent) == null) {
-              reject(cmd, "invalid project configuration: parent does not exist");
-              return;
-            }
-          }
-          validatePluginConfig(cmd, cfg);
-        } catch (Exception e) {
-          reject(cmd, "invalid project configuration");
-          logger.atSevere().withCause(e).log(
-              "User %s tried to push invalid project configuration %s for %s",
-              user.getLoggableName(), cmd.getNewId().name(), project.getName());
-          return;
-        }
-        break;
+        case DELETE:
+          break;
 
-      case DELETE:
-        break;
-
-      default:
-        reject(
-            cmd,
-            "prohibited by Gerrit: don't know how to handle config update of type "
-                + cmd.getType());
+        default:
+          reject(
+              cmd,
+              "prohibited by Gerrit: don't know how to handle config update of type "
+                  + cmd.getType());
+      }
     }
   }
 
@@ -1212,52 +1257,59 @@
 
   private void parseCreate(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
-    RevObject obj;
-    try {
-      obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
-      reject(cmd, "invalid object");
-      return;
-    }
-    logger.atFine().log("Creating %s", cmd);
+    try (TraceTimer traceTimer = newTimer("parseCreate")) {
+      RevObject obj;
+      try {
+        obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
+      } catch (IOException err) {
+        logger.atSevere().withCause(err).log(
+            "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
+        reject(cmd, "invalid object");
+        return;
+      }
+      logger.atFine().log("Creating %s", cmd);
 
-    if (isHead(cmd) && !isCommit(cmd)) {
-      return;
-    }
+      if (isHead(cmd) && !isCommit(cmd)) {
+        return;
+      }
 
-    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
-    try {
-      // Must pass explicit user instead of injecting a provider into CreateRefControl, since
-      // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
-      createRefControl.checkCreateRef(Providers.of(user), receivePack.getRepository(), branch, obj);
-    } catch (AuthException denied) {
-      rejectProhibited(cmd, denied);
-      return;
-    } catch (ResourceConflictException denied) {
-      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
-      return;
-    }
+      BranchNameKey branch = BranchNameKey.create(project.getName(), cmd.getRefName());
+      try {
+        // Must pass explicit user instead of injecting a provider into CreateRefControl, since
+        // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
+        createRefControl.checkCreateRef(
+            Providers.of(user), receivePack.getRepository(), branch, obj);
+      } catch (AuthException denied) {
+        rejectProhibited(cmd, denied);
+        return;
+      } catch (ResourceConflictException denied) {
+        reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
+        return;
+      }
 
-    if (validRefOperation(cmd)) {
-      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      if (validRefOperation(cmd)) {
+        validateRegularPushCommits(
+            BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+      }
     }
   }
 
   private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
-    logger.atFine().log("Updating %s", cmd);
-    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
-    if (!err.isPresent()) {
-      if (isHead(cmd) && !isCommit(cmd)) {
-        reject(cmd, "head must point to commit");
-        return;
+    try (TraceTimer traceTimer = TraceContext.newTimer("parseUpdate")) {
+      logger.atFine().log("Updating %s", cmd);
+      Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
+      if (!err.isPresent()) {
+        if (isHead(cmd) && !isCommit(cmd)) {
+          reject(cmd, "head must point to commit");
+          return;
+        }
+        if (validRefOperation(cmd)) {
+          validateRegularPushCommits(
+              BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+        }
+      } else {
+        rejectProhibited(cmd, err.get());
       }
-      if (validRefOperation(cmd)) {
-        validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      }
-    } else {
-      rejectProhibited(cmd, err.get());
     }
   }
 
@@ -1280,48 +1332,49 @@
   }
 
   private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logger.atFine().log("Deleting %s", cmd);
-    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
-      reject(cmd, "cannot delete changes");
-    } else if (isConfigRef(cmd.getRefName())) {
-      errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
-      reject(cmd, "cannot delete project configuration");
-    }
+    try (TraceTimer traceTimer = newTimer("parseDelete")) {
+      logger.atFine().log("Deleting %s", cmd);
+      if (cmd.getRefName().startsWith(REFS_CHANGES)) {
+        errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
+        reject(cmd, "cannot delete changes");
+      } else if (isConfigRef(cmd.getRefName())) {
+        errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
+        reject(cmd, "cannot delete project configuration");
+      }
 
-    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
-    if (!err.isPresent()) {
-      validRefOperation(cmd);
-
-    } else {
-      rejectProhibited(cmd, err.get());
+      Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
+      if (!err.isPresent()) {
+        validRefOperation(cmd);
+      } else {
+        rejectProhibited(cmd, err.get());
+      }
     }
   }
 
   private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
-    try {
-      receivePack.getRevWalk().parseCommit(cmd.getNewId());
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
-      reject(cmd, "invalid object");
-      return;
-    }
-    logger.atFine().log("Rewinding %s", cmd);
+    try (TraceTimer traceTimer = newTimer("parseRewind")) {
+      try {
+        receivePack.getRevWalk().parseCommit(cmd.getNewId());
+      } catch (IOException err) {
+        logger.atSevere().withCause(err).log(
+            "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
+        reject(cmd, "invalid object");
+        return;
+      }
+      logger.atFine().log("Rewinding %s", cmd);
 
-    if (!validRefOperation(cmd)) {
-      return;
-    }
-    validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-    if (cmd.getResult() != NOT_ATTEMPTED) {
-      return;
-    }
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        return;
+      }
 
-    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
-    if (!err.isPresent()) {
-      validRefOperation(cmd);
-    } else {
-      rejectProhibited(cmd, err.get());
+      Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
+      }
     }
   }
 
@@ -1362,11 +1415,21 @@
   static class MagicBranchInput {
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
+    private final IdentifiedUser user;
+    private final ProjectState projectState;
+    private final boolean defaultPublishComments;
+
     boolean deprecatedTopicSeen;
     final ReceiveCommand cmd;
     final LabelTypes labelTypes;
-    private final boolean defaultPublishComments;
-    Branch.NameKey dest;
+    /**
+     * Result of running {@link CommentValidator}-s on drafts that are published with the commit
+     * (which happens iff {@code --publish-comments} is set). Remains {@code true} if none are
+     * installed.
+     */
+    private boolean commentsValid = true;
+
+    BranchNameKey dest;
     PermissionBackend.ForRef perm;
     Set<String> reviewer = Sets.newLinkedHashSet();
     Set<String> cc = Sets.newLinkedHashSet();
@@ -1385,13 +1448,6 @@
     @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
     String topic;
 
-    @Option(
-        name = "--draft",
-        usage =
-            "Will be removed. Before that, this option will be mapped to '--private'"
-                + "for new changes and '--edit' for existing changes")
-    boolean draft;
-
     @Option(name = "--private", usage = "mark new/updated change as private")
     boolean isPrivate;
 
@@ -1515,18 +1571,23 @@
       if (!hashtag.isEmpty()) {
         hashtags.add(hashtag);
       }
-      // TODO(dpursehouse): validate hashtags
     }
 
-    MagicBranchInput(IdentifiedUser user, ReceiveCommand cmd, LabelTypes labelTypes) {
+    @UsedAt(UsedAt.Project.GOOGLE)
+    @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand")
+    private boolean createCodToken;
+
+    MagicBranchInput(
+        IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) {
+      this.user = user;
+      this.projectState = projectState;
       this.deprecatedTopicSeen = false;
       this.cmd = cmd;
-      this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
       this.labelTypes = labelTypes;
-      GeneralPreferencesInfo prefs = user.state().getGeneralPreferences();
+      GeneralPreferencesInfo prefs = user.state().generalPreferences();
       this.defaultPublishComments =
           prefs != null
-              ? firstNonNull(user.state().getGeneralPreferences().publishCommentsOnPush, false)
+              ? firstNonNull(user.state().generalPreferences().publishCommentsOnPush, false)
               : false;
     }
 
@@ -1564,7 +1625,15 @@
           .collect(toImmutableSet());
     }
 
+    void setCommentsValid(boolean commentsValid) {
+      this.commentsValid = commentsValid;
+    }
+
     boolean shouldPublishComments() {
+      if (!commentsValid) {
+        // Validation messages of type WARNING have already been added, now withhold the comments.
+        return false;
+      }
       if (publishComments) {
         return true;
       } else if (noPublishComments) {
@@ -1623,9 +1692,24 @@
       return ref.substring(0, split);
     }
 
+    public boolean shouldSetWorkInProgressOnNewChanges() {
+      // When wip or ready explicitly provided, leave it as is.
+      if (workInProgress) {
+        return true;
+      }
+      if (ready) {
+        return false;
+      }
+
+      return projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)
+          || firstNonNull(user.state().generalPreferences().workInProgressByDefault, false);
+    }
+
     NotifyResolver.Result getNotifyForNewChange() {
       return NotifyResolver.Result.create(
-          firstNonNull(notifyHandling, workInProgress ? NotifyHandling.OWNER : NotifyHandling.ALL),
+          firstNonNull(
+              notifyHandling,
+              shouldSetWorkInProgressOnNewChanges() ? NotifyHandling.OWNER : NotifyHandling.ALL),
           ImmutableSetMultimap.<RecipientType, Account.Id>builder()
               .putAll(RecipientType.TO, notifyTo)
               .putAll(RecipientType.CC, notifyCc)
@@ -1652,202 +1736,198 @@
    * <p>Assumes we are handling a magic branch here.
    */
   private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    logger.atFine().log("Found magic branch %s", cmd.getRefName());
-    MagicBranchInput magicBranch = new MagicBranchInput(user, cmd, labelTypes);
+    try (TraceTimer traceTimer = newTimer("parseMagicBranch")) {
+      logger.atFine().log("Found magic branch %s", cmd.getRefName());
+      MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes);
 
-    String ref;
-    magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
+      String ref;
+      magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
-    try {
-      ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
-    } catch (CmdLineException e) {
-      if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
-        logger.atFine().log("Invalid branch syntax");
-        reject(cmd, e.getMessage());
+      try {
+        ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
+      } catch (CmdLineException e) {
+        if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
+          logger.atFine().log("Invalid branch syntax");
+          reject(cmd, e.getMessage());
+          return;
+        }
+        ref = null; // never happens
+      }
+
+      if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+        reject(
+            cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
+      }
+
+      if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
+        StringWriter w = new StringWriter();
+        w.write("\nHelp for refs/for/branch:\n\n");
+        magicBranch.cmdLineParser.printUsage(w, null);
+        addMessage(w.toString());
+        reject(cmd, "see help");
         return;
       }
-      ref = null; // never happens
-    }
-
-    if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
-      reject(
-          cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
-    }
-
-    if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
-      StringWriter w = new StringWriter();
-      w.write("\nHelp for refs/for/branch:\n\n");
-      magicBranch.cmdLineParser.printUsage(w, null);
-      addMessage(w.toString());
-      reject(cmd, "see help");
-      return;
-    }
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
-      ref = RefNames.refsUsers(user.getAccountId());
-    }
-    // Pushing changes for review usually requires that the target branch exists, but there is an
-    // exception for the branch to which HEAD points to and for refs/meta/config. Pushing for
-    // review to these branches is allowed even if the branch does not exist yet. This allows to
-    // push initial code for review to an empty repository and to review an initial project
-    // configuration.
-    if (!receivePack.getAdvertisedRefs().containsKey(ref)
-        && !ref.equals(readHEAD(repo))
-        && !ref.equals(RefNames.REFS_CONFIG)) {
-      logger.atFine().log("Ref %s not found", ref);
-      if (ref.startsWith(Constants.R_HEADS)) {
-        String n = ref.substring(Constants.R_HEADS.length());
-        reject(cmd, "branch " + n + " not found");
-      } else {
-        reject(cmd, ref + " not found");
+      if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
+        logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
+        ref = RefNames.refsUsers(user.getAccountId());
       }
-      return;
-    }
+      // Pushing changes for review usually requires that the target branch exists, but there is an
+      // exception for the branch to which HEAD points to and for refs/meta/config. Pushing for
+      // review to these branches is allowed even if the branch does not exist yet. This allows to
+      // push initial code for review to an empty repository and to review an initial project
+      // configuration.
+      if (!receivePack.getAdvertisedRefs().containsKey(ref)
+          && !ref.equals(readHEAD(repo))
+          && !ref.equals(RefNames.REFS_CONFIG)) {
+        logger.atFine().log("Ref %s not found", ref);
+        if (ref.startsWith(Constants.R_HEADS)) {
+          String n = ref.substring(Constants.R_HEADS.length());
+          reject(cmd, "branch " + n + " not found");
+        } else {
+          reject(cmd, ref + " not found");
+        }
+        return;
+      }
 
-    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
-    magicBranch.perm = permissions.ref(ref);
+      magicBranch.dest = BranchNameKey.create(project.getNameKey(), ref);
+      magicBranch.perm = permissions.ref(ref);
 
-    Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
-    if (err.isPresent()) {
-      rejectProhibited(cmd, err.get());
-      return;
-    }
-
-    // TODO(davido): Remove legacy support for drafts magic branch option
-    // after repo-tool supports private and work-in-progress changes.
-    if (magicBranch.draft && !receiveConfig.allowDrafts) {
-      errors.put(CODE_REVIEW_ERROR, ref);
-      reject(cmd, "draft workflow is disabled");
-      return;
-    }
-
-    if (magicBranch.isPrivate && magicBranch.removePrivate) {
-      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
-      return;
-    }
-
-    boolean privateByDefault =
-        projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
-    setChangeAsPrivate =
-        magicBranch.draft
-            || magicBranch.isPrivate
-            || (privateByDefault && !magicBranch.removePrivate);
-
-    if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
-      reject(cmd, "private changes are disabled");
-      return;
-    }
-
-    if (magicBranch.workInProgress && magicBranch.ready) {
-      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
-      return;
-    }
-    if (magicBranch.publishComments && magicBranch.noPublishComments) {
-      reject(
-          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
-      return;
-    }
-
-    if (magicBranch.submit) {
-      err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+      Optional<AuthException> err =
+          checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
       if (err.isPresent()) {
         rejectProhibited(cmd, err.get());
         return;
       }
-    }
 
-    RevWalk walk = receivePack.getRevWalk();
-    RevCommit tip;
-    try {
-      tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logger.atFine().log("Tip of push: %s", tip.name());
-    } catch (IOException ex) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logger.atSevere().withCause(ex).log("Invalid pack upload; one or more objects weren't sent");
-      return;
-    }
+      if (magicBranch.isPrivate && magicBranch.removePrivate) {
+        reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+        return;
+      }
 
-    String destBranch = magicBranch.dest.get();
-    try {
-      if (magicBranch.merged) {
+      boolean privateByDefault =
+          projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+      setChangeAsPrivate =
+          magicBranch.isPrivate || (privateByDefault && !magicBranch.removePrivate);
+
+      if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
+        reject(cmd, "private changes are disabled");
+        return;
+      }
+
+      if (magicBranch.workInProgress && magicBranch.ready) {
+        reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+        return;
+      }
+      if (magicBranch.publishComments && magicBranch.noPublishComments) {
+        reject(
+            cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+        return;
+      }
+
+      if (magicBranch.submit) {
+        err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+        if (err.isPresent()) {
+          rejectProhibited(cmd, err.get());
+          return;
+        }
+      }
+
+      RevWalk walk = receivePack.getRevWalk();
+      RevCommit tip;
+      try {
+        tip = walk.parseCommit(magicBranch.cmd.getNewId());
+        logger.atFine().log("Tip of push: %s", tip.name());
+      } catch (IOException ex) {
+        magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(ex).log(
+            "Invalid pack upload; one or more objects weren't sent");
+        return;
+      }
+
+      String destBranch = magicBranch.dest.branch();
+      try {
+        if (magicBranch.merged) {
+          if (magicBranch.base != null) {
+            reject(cmd, "cannot use merged with base");
+            return;
+          }
+          RevCommit branchTip = readBranchTip(magicBranch.dest);
+          if (branchTip == null) {
+            reject(cmd, magicBranch.dest.branch() + " not found");
+            return;
+          }
+          if (!walk.isMergedInto(tip, branchTip)) {
+            reject(cmd, "not merged into branch");
+            return;
+          }
+        }
+
+        // If tip is a merge commit, or the root commit or
+        // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+        if (tip.getParentCount() > 1
+            || magicBranch.base != null
+            || magicBranch.merged
+            || tip.getParentCount() == 0) {
+          logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
+          newChangeForAllNotInTarget = false;
+        }
+
         if (magicBranch.base != null) {
-          reject(cmd, "cannot use merged with base");
-          return;
-        }
-        RevCommit branchTip = readBranchTip(magicBranch.dest);
-        if (branchTip == null) {
-          reject(cmd, magicBranch.dest.get() + " not found");
-          return;
-        }
-        if (!walk.isMergedInto(tip, branchTip)) {
-          reject(cmd, "not merged into branch");
-          return;
-        }
-      }
-
-      // If tip is a merge commit, or the root commit or
-      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
-      if (tip.getParentCount() > 1
-          || magicBranch.base != null
-          || magicBranch.merged
-          || tip.getParentCount() == 0) {
-        logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
-        newChangeForAllNotInTarget = false;
-      }
-
-      if (magicBranch.base != null) {
-        logger.atFine().log("Handling %%base: %s", magicBranch.base);
-        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
-        for (ObjectId id : magicBranch.base) {
-          try {
-            magicBranch.baseCommit.add(walk.parseCommit(id));
-          } catch (IncorrectObjectTypeException notCommit) {
-            reject(cmd, "base must be a commit");
-            return;
-          } catch (MissingObjectException e) {
-            reject(cmd, "base not found");
-            return;
-          } catch (IOException e) {
-            logger.atWarning().withCause(e).log(
-                "Project %s cannot read %s", project.getName(), id.name());
-            reject(cmd, INTERNAL_SERVER_ERROR);
-            return;
+          logger.atFine().log("Handling %%base: %s", magicBranch.base);
+          magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
+          for (ObjectId id : magicBranch.base) {
+            try {
+              magicBranch.baseCommit.add(walk.parseCommit(id));
+            } catch (IncorrectObjectTypeException notCommit) {
+              reject(cmd, "base must be a commit");
+              return;
+            } catch (MissingObjectException e) {
+              reject(cmd, "base not found");
+              return;
+            } catch (IOException e) {
+              logger.atWarning().withCause(e).log(
+                  "Project %s cannot read %s", project.getName(), id.name());
+              reject(cmd, INTERNAL_SERVER_ERROR);
+              return;
+            }
+          }
+        } else if (newChangeForAllNotInTarget) {
+          RevCommit branchTip = readBranchTip(magicBranch.dest);
+          if (branchTip != null) {
+            magicBranch.baseCommit = Collections.singletonList(branchTip);
+            logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
+          } else {
+            // The target branch does not exist. Usually pushing changes for review requires that
+            // the
+            // target branch exists, but there is an exception for the branch to which HEAD points
+            // to
+            // and for refs/meta/config. Pushing for review to these branches is allowed even if the
+            // branch does not exist yet. This allows to push initial code for review to an empty
+            // repository and to review an initial project configuration.
+            if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
+              reject(cmd, magicBranch.dest.branch() + " not found");
+              return;
+            }
           }
         }
-      } else if (newChangeForAllNotInTarget) {
-        RevCommit branchTip = readBranchTip(magicBranch.dest);
-        if (branchTip != null) {
-          magicBranch.baseCommit = Collections.singletonList(branchTip);
-          logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
-        } else {
-          // The target branch does not exist. Usually pushing changes for review requires that the
-          // target branch exists, but there is an exception for the branch to which HEAD points to
-          // and for refs/meta/config. Pushing for review to these branches is allowed even if the
-          // branch does not exist yet. This allows to push initial code for review to an empty
-          // repository and to review an initial project configuration.
-          if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
-            reject(cmd, magicBranch.dest.get() + " not found");
-            return;
-          }
-        }
+      } catch (IOException ex) {
+        logger.atWarning().withCause(ex).log(
+            "Error walking to %s in project %s", destBranch, project.getName());
+        reject(cmd, INTERNAL_SERVER_ERROR);
+        return;
       }
-    } catch (IOException ex) {
-      logger.atWarning().withCause(ex).log(
-          "Error walking to %s in project %s", destBranch, project.getName());
-      reject(cmd, INTERNAL_SERVER_ERROR);
-      return;
-    }
 
-    if (magicBranch.deprecatedTopicSeen) {
-      messages.add(
-          new ValidationMessage(
-              "WARNING: deprecated topic syntax. Use -o topic=TOPIC instead", false));
-      logger.atInfo().log("deprecated topic push seen for project %s", project.getName());
-    }
+      if (magicBranch.deprecatedTopicSeen) {
+        messages.add(
+            new ValidationMessage(
+                "WARNING: deprecated topic syntax. Use -o topic=TOPIC instead", false));
+        logger.atInfo().log("deprecated topic push seen for project %s", project.getName());
+      }
 
-    if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
-      this.magicBranch = magicBranch;
-      this.resultChangeIds.setMagicPush(true);
+      if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
+        this.magicBranch = magicBranch;
+        this.resultChangeIds.setMagicPush(true);
+      }
     }
   }
 
@@ -1855,42 +1935,45 @@
   // branch.  If they aren't, we want to abort. We do this check by
   // looking to see if we can compute a merge base between the new
   // commits and the target branch head.
-  private boolean validateConnected(ReceiveCommand cmd, Branch.NameKey dest, RevCommit tip) {
-    RevWalk walk = receivePack.getRevWalk();
-    try {
-      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.get());
-      if (targetRef == null || targetRef.getObjectId() == null) {
-        // The destination branch does not yet exist. Assume the
-        // history being sent for review will start it and thus
-        // is "connected" to the branch.
-        logger.atFine().log("Branch is unborn");
-
-        // This is not an error condition.
-        return true;
-      }
-
-      RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logger.atFine().log("Current branch tip: %s", h.name());
-      RevFilter oldRevFilter = walk.getRevFilter();
+  private boolean validateConnected(ReceiveCommand cmd, BranchNameKey dest, RevCommit tip) {
+    try (TraceTimer traceTimer =
+        newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) {
+      RevWalk walk = receivePack.getRevWalk();
       try {
-        walk.reset();
-        walk.setRevFilter(RevFilter.MERGE_BASE);
-        walk.markStart(tip);
-        walk.markStart(h);
-        if (walk.next() == null) {
-          reject(cmd, "no common ancestry");
-          return false;
+        Ref targetRef = receivePack.getAdvertisedRefs().get(dest.branch());
+        if (targetRef == null || targetRef.getObjectId() == null) {
+          // The destination branch does not yet exist. Assume the
+          // history being sent for review will start it and thus
+          // is "connected" to the branch.
+          logger.atFine().log("Branch is unborn");
+
+          // This is not an error condition.
+          return true;
         }
-      } finally {
-        walk.reset();
-        walk.setRevFilter(oldRevFilter);
+
+        RevCommit h = walk.parseCommit(targetRef.getObjectId());
+        logger.atFine().log("Current branch tip: %s", h.name());
+        RevFilter oldRevFilter = walk.getRevFilter();
+        try {
+          walk.reset();
+          walk.setRevFilter(RevFilter.MERGE_BASE);
+          walk.markStart(tip);
+          walk.markStart(h);
+          if (walk.next() == null) {
+            reject(cmd, "no common ancestry");
+            return false;
+          }
+        } finally {
+          walk.reset();
+          walk.setRevFilter(oldRevFilter);
+        }
+      } catch (IOException e) {
+        cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+        return false;
       }
-    } catch (IOException e) {
-      cmd.setResult(REJECTED_MISSING_OBJECT);
-      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
-      return false;
+      return true;
     }
-    return true;
   }
 
   private static String readHEAD(Repository repo) {
@@ -1904,89 +1987,64 @@
     }
   }
 
-  private RevCommit readBranchTip(Branch.NameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.get());
+  private RevCommit readBranchTip(BranchNameKey branch) throws IOException {
+    Ref r = allRefs().get(branch.branch());
     if (r == null) {
       return null;
     }
     return receivePack.getRevWalk().parseCommit(r.getObjectId());
   }
 
-  // Handle an upload to refs/changes/XX/CHANGED-NUMBER.
-  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logger.atFine().log("Parsing replace command");
-    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
-      reject(cmd, "invalid usage");
-      return;
-    }
-
-    RevCommit newCommit;
-    try {
-      newCommit = receivePack.getRevWalk().parseCommit(cmd.getNewId());
-      logger.atFine().log("Replacing with %s", newCommit);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot parse %s as commit", cmd.getNewId().name());
-      reject(cmd, "invalid commit");
-      return;
-    }
-
-    Change changeEnt;
-    try {
-      changeEnt = notesFactory.createChecked(project.getNameKey(), changeId).getChange();
-    } catch (NoSuchChangeException e) {
-      logger.atSevere().withCause(e).log("Change not found %s", changeId);
-      reject(cmd, "change " + changeId + " not found");
-      return;
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Cannot lookup existing change %s", changeId);
-      reject(cmd, "database error");
-      return;
-    }
-    if (!project.getNameKey().equals(changeEnt.getProject())) {
-      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
-      return;
-    }
-
-    BranchCommitValidator validator =
-        commitValidatorFactory.create(projectState, changeEnt.getDest(), user);
-    try {
-      if (validator.validCommit(
-          receivePack.getRevWalk().getObjectReader(),
-          cmd,
-          newCommit,
-          false,
-          messages,
-          rejectCommits,
-          changeEnt)) {
-        logger.atFine().log("Replacing change %s", changeEnt.getId());
-        requestReplace(cmd, true, changeEnt, newCommit);
-      }
-    } catch (IOException e) {
-      reject(cmd, "I/O exception validating commit");
-    }
-  }
-
   /**
-   * Add an update for an existing change. Returns true if it succeeded; rejects the command if it
-   * failed.
+   * Update an existing change. If draft comments are to be published, these are validated and may
+   * be withheld.
+   *
+   * @return True if the command succeeded, false if it was rejected.
    */
-  private boolean requestReplace(
+  private boolean requestReplaceAndValidateComments(
       ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
-    if (change.isClosed()) {
-      reject(
-          cmd,
-          changeFormatter.changeClosed(
-              ChangeReportFormatter.Input.builder().setChange(change).build()));
-      return false;
-    }
+    try (TraceTimer traceTimer = newTimer("requestReplaceAndValidateComments")) {
+      if (change.isClosed()) {
+        reject(
+            cmd,
+            changeFormatter.changeClosed(
+                ChangeReportFormatter.Input.builder().setChange(change).build()));
+        return false;
+      }
 
-    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
-    if (replaceByChange.containsKey(req.ontoChange)) {
-      reject(cmd, "duplicate request");
-      return false;
+      ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
+      if (replaceByChange.containsKey(req.ontoChange)) {
+        reject(cmd, "duplicate request");
+        return false;
+      }
+
+      if (magicBranch != null && magicBranch.shouldPublishComments()) {
+        List<Comment> drafts =
+            commentsUtil.draftByChangeAuthor(
+                notesFactory.createChecked(change), user.getAccountId());
+        ImmutableList<CommentForValidation> draftsForValidation =
+            drafts.stream()
+                .map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment.lineNbr > 0
+                                ? CommentType.INLINE_COMMENT
+                                : CommentType.FILE_COMMENT,
+                            comment.message))
+                .collect(toImmutableList());
+        ImmutableList<CommentValidationFailure> commentValidationFailures =
+            PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
+        magicBranch.setCommentsValid(commentValidationFailures.isEmpty());
+        commentValidationFailures.forEach(
+            failure ->
+                addMessage(
+                    "Comment validation failure: " + failure.getMessage(),
+                    ValidationMessage.Type.WARNING));
+      }
+
+      replaceByChange.put(req.ontoChange, req);
+      return true;
     }
-    replaceByChange.put(req.ontoChange, req);
-    return true;
   }
 
   private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
@@ -2007,351 +2065,367 @@
   }
 
   private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
-    logger.atFine().log("Finding new and replaced changes");
-    List<CreateRequest> newChanges = new ArrayList<>();
+    try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
+      logger.atFine().log("Finding new and replaced changes");
+      List<CreateRequest> newChanges = new ArrayList<>();
 
-    ListMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector =
-        GroupCollector.create(changeRefsById(), psUtil, notesFactory, project.getNameKey());
+      ListMultimap<ObjectId, Ref> existing = changeRefsById();
+      GroupCollector groupCollector =
+          GroupCollector.create(changeRefsById(), psUtil, notesFactory, project.getNameKey());
 
-    BranchCommitValidator validator =
-        commitValidatorFactory.create(projectState, magicBranch.dest, user);
+      BranchCommitValidator validator =
+          commitValidatorFactory.create(projectState, magicBranch.dest, user);
 
-    try {
-      RevCommit start = setUpWalkForSelectingChanges();
-      if (start == null) {
-        return Collections.emptyList();
-      }
-
-      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
-      Set<Change.Key> newChangeIds = new HashSet<>();
-      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
-      int total = 0;
-      int alreadyTracked = 0;
-      boolean rejectImplicitMerges =
-          start.getParentCount() == 1
-              && projectCache
-                  .get(project.getNameKey())
-                  .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
-              // Don't worry about implicit merges when creating changes for
-              // already-merged commits; they're already in history, so it's too
-              // late.
-              && !magicBranch.merged;
-      Set<RevCommit> mergedParents;
-      if (rejectImplicitMerges) {
-        mergedParents = new HashSet<>();
-      } else {
-        mergedParents = null;
-      }
-
-      for (; ; ) {
-        RevCommit c = receivePack.getRevWalk().next();
-        if (c == null) {
-          break;
+      try {
+        RevCommit start = setUpWalkForSelectingChanges();
+        if (start == null) {
+          return Collections.emptyList();
         }
-        total++;
-        receivePack.getRevWalk().parseBody(c);
-        String name = c.name();
-        groupCollector.visit(c);
-        Collection<Ref> existingRefs = existing.get(c);
 
+        LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
+        Set<Change.Key> newChangeIds = new HashSet<>();
+        int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+        int total = 0;
+        int alreadyTracked = 0;
+        boolean rejectImplicitMerges =
+            start.getParentCount() == 1
+                && projectCache
+                    .get(project.getNameKey())
+                    .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
+                // Don't worry about implicit merges when creating changes for
+                // already-merged commits; they're already in history, so it's too
+                // late.
+                && !magicBranch.merged;
+        Set<RevCommit> mergedParents;
         if (rejectImplicitMerges) {
-          Collections.addAll(mergedParents, c.getParents());
-          mergedParents.remove(c);
-        }
-
-        boolean commitAlreadyTracked = !existingRefs.isEmpty();
-        if (commitAlreadyTracked) {
-          alreadyTracked++;
-          // Corner cases where an existing commit might need a new group:
-          // A) Existing commit has a null group; wasn't assigned during schema
-          //    upgrade, or schema upgrade is performed on a running server.
-          // B) Let A<-B<-C, then:
-          //      1. Push A to refs/heads/master
-          //      2. Push B to refs/for/master
-          //      3. Force push A~ to refs/heads/master
-          //      4. Push C to refs/for/master.
-          //      B will be in existing so we aren't replacing the patch set. It
-          //      used to have its own group, but now needs to to be changed to
-          //      A's group.
-          // C) Commit is a PatchSet of a pre-existing change uploaded with a
-          //    different target branch.
-          for (Ref ref : existingRefs) {
-            updateGroups.add(new UpdateGroupsRequest(ref, c));
-          }
-          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
-            continue;
-          }
-        }
-
-        List<String> idList = c.getFooterLines(CHANGE_ID);
-        if (!idList.isEmpty()) {
-          pending.put(
-              c, lookupByChangeKey(c, new Change.Key(idList.get(idList.size() - 1).trim())));
+          mergedParents = new HashSet<>();
         } else {
-          pending.put(c, lookupByCommit(c));
+          mergedParents = null;
         }
 
-        int n = pending.size() + newChanges.size();
-        if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
-          reject(
-              magicBranch.cmd,
-              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-          return Collections.emptyList();
-        }
+        for (; ; ) {
+          RevCommit c = receivePack.getRevWalk().next();
+          if (c == null) {
+            break;
+          }
+          total++;
+          receivePack.getRevWalk().parseBody(c);
+          String name = c.name();
+          groupCollector.visit(c);
+          Collection<Ref> existingRefs = existing.get(c);
 
-        if (commitAlreadyTracked) {
-          boolean changeExistsOnDestBranch = false;
-          for (ChangeData cd : pending.get(c).destChanges) {
-            if (cd.change().getDest().equals(magicBranch.dest)) {
-              changeExistsOnDestBranch = true;
-              break;
+          if (rejectImplicitMerges) {
+            Collections.addAll(mergedParents, c.getParents());
+            mergedParents.remove(c);
+          }
+
+          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          if (commitAlreadyTracked) {
+            alreadyTracked++;
+            // Corner cases where an existing commit might need a new group:
+            // A) Existing commit has a null group; wasn't assigned during schema
+            //    upgrade, or schema upgrade is performed on a running server.
+            // B) Let A<-B<-C, then:
+            //      1. Push A to refs/heads/master
+            //      2. Push B to refs/for/master
+            //      3. Force push A~ to refs/heads/master
+            //      4. Push C to refs/for/master.
+            //      B will be in existing so we aren't replacing the patch set. It
+            //      used to have its own group, but now needs to to be changed to
+            //      A's group.
+            // C) Commit is a PatchSet of a pre-existing change uploaded with a
+            //    different target branch.
+            for (Ref ref : existingRefs) {
+              updateGroups.add(new UpdateGroupsRequest(ref, c));
             }
-          }
-          if (changeExistsOnDestBranch) {
-            continue;
-          }
-
-          logger.atFine().log("Creating new change for %s even though it is already tracked", name);
-        }
-
-        if (!validator.validCommit(
-            receivePack.getRevWalk().getObjectReader(),
-            magicBranch.cmd,
-            c,
-            magicBranch.merged,
-            messages,
-            rejectCommits,
-            null)) {
-          // Not a change the user can propose? Abort as early as possible.
-          logger.atFine().log("Aborting early due to invalid commit");
-          return Collections.emptyList();
-        }
-
-        // Don't allow merges to be uploaded in commit chain via all-not-in-target
-        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
-          reject(
-              magicBranch.cmd,
-              "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
-                  + "to override please set the base manually");
-          logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
-          // TODO(dborowitz): Should we early return here?
-        }
-
-        if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get(), newProgress));
-          continue;
-        }
-      }
-      logger.atFine().log(
-          "Finished initial RevWalk with %d commits total: %d already"
-              + " tracked, %d new changes with no Change-Id, and %d deferred"
-              + " lookups",
-          total, alreadyTracked, newChanges.size(), pending.size());
-
-      if (rejectImplicitMerges) {
-        rejectImplicitMerges(mergedParents);
-      }
-
-      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
-        ChangeLookup p = itr.next();
-        if (p.changeKey == null) {
-          continue;
-        }
-
-        if (newChangeIds.contains(p.changeKey)) {
-          logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
-          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          return Collections.emptyList();
-        }
-
-        List<ChangeData> changes = p.destChanges;
-        if (changes.size() > 1) {
-          logger.atFine().log(
-              "Multiple changes in branch %s with Change-Id %s: %s",
-              magicBranch.dest,
-              p.changeKey,
-              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
-          // WTF, multiple changes in this branch have the same key?
-          // Since the commit is new, the user should recreate it with
-          // a different Change-Id. In practice, we should never see
-          // this error message as Change-Id should be unique per branch.
-          //
-          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-          return Collections.emptyList();
-        }
-
-        if (changes.size() == 1) {
-          // Schedule as a replacement to this one matching change.
-          //
-
-          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
-          // If Commit is already current PatchSet of target Change.
-          if (p.commit.name().equals(currentPs.get())) {
-            if (pending.size() == 1) {
-              // There are no commits left to check, all commits in pending were already
-              // current PatchSet of the corresponding target changes.
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-            } else {
-              // Commit is already current PatchSet.
-              // Remove from pending and try next commit.
-              itr.remove();
+            if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
             }
           }
-          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
-            continue;
-          }
-          return Collections.emptyList();
-        }
 
-        if (changes.isEmpty()) {
-          if (!isValidChangeId(p.changeKey.get())) {
-            reject(magicBranch.cmd, "invalid Change-Id");
+          List<String> idList = c.getFooterLines(FooterConstants.CHANGE_ID);
+          if (!idList.isEmpty()) {
+            pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
+          } else {
+            pending.put(c, lookupByCommit(c));
+          }
+
+          int n = pending.size() + newChanges.size();
+          if (maxBatchChanges != 0 && n > maxBatchChanges) {
+            logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
+            reject(
+                magicBranch.cmd,
+                "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
             return Collections.emptyList();
           }
 
-          // In case the change look up from the index failed,
-          // double check against the existing refs
-          if (foundInExistingRef(existing.get(p.commit))) {
-            if (pending.size() == 1) {
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-              return Collections.emptyList();
+          if (commitAlreadyTracked) {
+            boolean changeExistsOnDestBranch = false;
+            for (ChangeData cd : pending.get(c).destChanges) {
+              if (cd.change().getDest().equals(magicBranch.dest)) {
+                changeExistsOnDestBranch = true;
+                break;
+              }
             }
-            itr.remove();
+            if (changeExistsOnDestBranch) {
+              continue;
+            }
+
+            logger.atFine().log(
+                "Creating new change for %s even though it is already tracked", name);
+          }
+
+          BranchCommitValidator.Result validationResult =
+              validator.validateCommit(
+                  receivePack.getRevWalk().getObjectReader(),
+                  magicBranch.cmd,
+                  c,
+                  magicBranch.merged,
+                  rejectCommits,
+                  null);
+          messages.addAll(validationResult.messages());
+          if (!validationResult.isValid()) {
+            // Not a change the user can propose? Abort as early as possible.
+            logger.atFine().log("Aborting early due to invalid commit");
+            return Collections.emptyList();
+          }
+
+          // Don't allow merges to be uploaded in commit chain via all-not-in-target
+          if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
+            reject(
+                magicBranch.cmd,
+                "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+                    + "to override please set the base manually");
+            logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
+            // TODO(dborowitz): Should we early return here?
+          }
+
+          if (idList.isEmpty()) {
+            newChanges.add(new CreateRequest(c, magicBranch.dest.branch(), newProgress));
             continue;
           }
-          newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get(), newProgress));
-      }
-      logger.atFine().log(
-          "Finished deferred lookups with %d updates and %d new changes",
-          replaceByChange.size(), newChanges.size());
-    } catch (IOException e) {
-      // Should never happen, the core receive process would have
-      // identified the missing object earlier before we got control.
-      //
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
-      return Collections.emptyList();
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
-      reject(magicBranch.cmd, "database error");
-      return Collections.emptyList();
-    }
+        logger.atFine().log(
+            "Finished initial RevWalk with %d commits total: %d already"
+                + " tracked, %d new changes with no Change-Id, and %d deferred"
+                + " lookups",
+            total, alreadyTracked, newChanges.size(), pending.size());
 
-    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
-      reject(magicBranch.cmd, "no new changes");
-      return Collections.emptyList();
-    }
-    if (!newChanges.isEmpty() && magicBranch.edit) {
-      reject(magicBranch.cmd, "edit is not supported for new changes");
+        if (rejectImplicitMerges) {
+          rejectImplicitMerges(mergedParents);
+        }
+
+        for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
+          ChangeLookup p = itr.next();
+          if (p.changeKey == null) {
+            continue;
+          }
+
+          if (newChangeIds.contains(p.changeKey)) {
+            logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
+            reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+            return Collections.emptyList();
+          }
+
+          List<ChangeData> changes = p.destChanges;
+          if (changes.size() > 1) {
+            logger.atFine().log(
+                "Multiple changes in branch %s with Change-Id %s: %s",
+                magicBranch.dest,
+                p.changeKey,
+                changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
+            // WTF, multiple changes in this branch have the same key?
+            // Since the commit is new, the user should recreate it with
+            // a different Change-Id. In practice, we should never see
+            // this error message as Change-Id should be unique per branch.
+            //
+            reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+            return Collections.emptyList();
+          }
+
+          if (changes.size() == 1) {
+            // Schedule as a replacement to this one matching change.
+            //
+
+            ObjectId currentPs = changes.get(0).currentPatchSet().commitId();
+            // If Commit is already current PatchSet of target Change.
+            if (p.commit.equals(currentPs)) {
+              if (pending.size() == 1) {
+                // There are no commits left to check, all commits in pending were already
+                // current PatchSet of the corresponding target changes.
+                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+              } else {
+                // Commit is already current PatchSet.
+                // Remove from pending and try next commit.
+                itr.remove();
+                continue;
+              }
+            }
+            if (requestReplaceAndValidateComments(
+                magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+              continue;
+            }
+            return Collections.emptyList();
+          }
+
+          if (changes.isEmpty()) {
+            if (!isValidChangeId(p.changeKey.get())) {
+              reject(magicBranch.cmd, "invalid Change-Id");
+              return Collections.emptyList();
+            }
+
+            // In case the change look up from the index failed,
+            // double check against the existing refs
+            if (foundInExistingRef(existing.get(p.commit))) {
+              if (pending.size() == 1) {
+                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+                return Collections.emptyList();
+              }
+              itr.remove();
+              continue;
+            }
+            newChangeIds.add(p.changeKey);
+          }
+          newChanges.add(new CreateRequest(p.commit, magicBranch.dest.branch(), newProgress));
+        }
+        logger.atFine().log(
+            "Finished deferred lookups with %d updates and %d new changes",
+            replaceByChange.size(), newChanges.size());
+      } catch (IOException e) {
+        // Should never happen, the core receive process would have
+        // identified the missing object earlier before we got control.
+        //
+        magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+        return Collections.emptyList();
+      } catch (StorageException e) {
+        logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
+        reject(magicBranch.cmd, "database error");
+        return Collections.emptyList();
+      }
+
+      if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
+        reject(magicBranch.cmd, "no new changes");
+        return Collections.emptyList();
+      }
+      if (!newChanges.isEmpty() && magicBranch.edit) {
+        reject(magicBranch.cmd, "edit is not supported for new changes");
+        return newChanges;
+      }
+
+      try {
+        SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+        List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+        for (int i = 0; i < newChanges.size(); i++) {
+          CreateRequest create = newChanges.get(i);
+          create.setChangeId(newIds.get(i));
+          create.groups = ImmutableList.copyOf(groups.get(create.commit));
+        }
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
+        }
+        for (UpdateGroupsRequest update : updateGroups) {
+          update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+        }
+        logger.atFine().log("Finished updating groups from GroupCollector");
+      } catch (StorageException e) {
+        logger.atSevere().withCause(e).log("Error collecting groups for changes");
+        reject(magicBranch.cmd, INTERNAL_SERVER_ERROR);
+      }
       return newChanges;
     }
-
-    try {
-      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
-      for (int i = 0; i < newChanges.size(); i++) {
-        CreateRequest create = newChanges.get(i);
-        create.setChangeId(newIds.get(i));
-        create.groups = ImmutableList.copyOf(groups.get(create.commit));
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
-      }
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
-      }
-      logger.atFine().log("Finished updating groups from GroupCollector");
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Error collecting groups for changes");
-      reject(magicBranch.cmd, INTERNAL_SERVER_ERROR);
-    }
-    return newChanges;
   }
 
   private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    for (Ref ref : existingRefs) {
-      ChangeNotes notes =
-          notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
-      Change change = notes.getChange();
-      if (change.getDest().equals(magicBranch.dest)) {
-        logger.atFine().log("Found change %s from existing refs.", change.getKey());
-        // Reindex the change asynchronously, ignoring errors.
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
-        return true;
+    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
+      for (Ref ref : existingRefs) {
+        ChangeNotes notes =
+            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+        Change change = notes.getChange();
+        if (change.getDest().equals(magicBranch.dest)) {
+          logger.atFine().log("Found change %s from existing refs.", change.getKey());
+          // Reindex the change asynchronously, ignoring errors.
+          @SuppressWarnings("unused")
+          Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
+          return true;
+        }
       }
+      return false;
     }
-    return false;
   }
 
   private RevCommit setUpWalkForSelectingChanges() throws IOException {
-    RevWalk rw = receivePack.getRevWalk();
-    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+    try (TraceTimer traceTimer = newTimer("setUpWalkForSelectingChanges")) {
+      RevWalk rw = receivePack.getRevWalk();
+      RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
 
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-    receivePack.getRevWalk().markStart(start);
-    if (magicBranch.baseCommit != null) {
-      markExplicitBasesUninteresting();
-    } else if (magicBranch.merged) {
-      logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
-      for (RevCommit c : start.getParents()) {
-        rw.markUninteresting(c);
+      rw.reset();
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE, true);
+      receivePack.getRevWalk().markStart(start);
+      if (magicBranch.baseCommit != null) {
+        markExplicitBasesUninteresting();
+      } else if (magicBranch.merged) {
+        logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
+        for (RevCommit c : start.getParents()) {
+          rw.markUninteresting(c);
+        }
+      } else {
+        markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.branch() : null);
       }
-    } else {
-      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
+      return start;
     }
-    return start;
   }
 
   private void markExplicitBasesUninteresting() throws IOException {
-    logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
-    for (RevCommit c : magicBranch.baseCommit) {
-      receivePack.getRevWalk().markUninteresting(c);
-    }
-    Ref targetRef = allRefs().get(magicBranch.dest.get());
-    if (targetRef != null) {
-      logger.atFine().log(
-          "Marking target ref %s (%s) uninteresting",
-          magicBranch.dest.get(), targetRef.getObjectId().name());
-      receivePack
-          .getRevWalk()
-          .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
+    try (TraceTimer traceTimer = newTimer("markExplicitBasesUninteresting")) {
+      logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
+      for (RevCommit c : magicBranch.baseCommit) {
+        receivePack.getRevWalk().markUninteresting(c);
+      }
+      Ref targetRef = allRefs().get(magicBranch.dest.branch());
+      if (targetRef != null) {
+        logger.atFine().log(
+            "Marking target ref %s (%s) uninteresting",
+            magicBranch.dest.branch(), targetRef.getObjectId().name());
+        receivePack
+            .getRevWalk()
+            .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
+      }
     }
   }
 
   private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
-    if (!mergedParents.isEmpty()) {
-      Ref targetRef = allRefs().get(magicBranch.dest.get());
-      if (targetRef != null) {
-        RevWalk rw = receivePack.getRevWalk();
-        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
-        boolean containsImplicitMerges = true;
-        for (RevCommit p : mergedParents) {
-          containsImplicitMerges &= !rw.isMergedInto(p, tip);
-        }
-
-        if (containsImplicitMerges) {
-          rw.reset();
+    try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) {
+      if (!mergedParents.isEmpty()) {
+        Ref targetRef = allRefs().get(magicBranch.dest.branch());
+        if (targetRef != null) {
+          RevWalk rw = receivePack.getRevWalk();
+          RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+          boolean containsImplicitMerges = true;
           for (RevCommit p : mergedParents) {
-            rw.markStart(p);
+            containsImplicitMerges &= !rw.isMergedInto(p, tip);
           }
-          rw.markUninteresting(tip);
-          RevCommit c;
-          while ((c = rw.next()) != null) {
-            rw.parseBody(c);
-            messages.add(
-                new CommitValidationMessage(
-                    "Implicit Merge of " + c.abbreviate(7).name() + " " + c.getShortMessage(),
-                    ValidationMessage.Type.ERROR));
+
+          if (containsImplicitMerges) {
+            rw.reset();
+            for (RevCommit p : mergedParents) {
+              rw.markStart(p);
+            }
+            rw.markUninteresting(tip);
+            RevCommit c;
+            while ((c = rw.next()) != null) {
+              rw.parseBody(c);
+              messages.add(
+                  new CommitValidationMessage(
+                      "Implicit Merge of "
+                          + abbreviateName(c, rw.getObjectReader())
+                          + " "
+                          + c.getShortMessage(),
+                      ValidationMessage.Type.ERROR));
+            }
+            reject(magicBranch.cmd, "implicit merges detected");
           }
-          reject(magicBranch.cmd, "implicit merges detected");
         }
       }
     }
@@ -2360,20 +2434,23 @@
   // Mark all branch tips as uninteresting in the given revwalk,
   // so we get only the new commits when walking rw.
   private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
-    int i = 0;
-    for (Ref ref : allRefs().values()) {
-      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
-          && ref.getObjectId() != null) {
-        try {
-          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
-          i++;
-        } catch (IOException e) {
-          logger.atWarning().withCause(e).log(
-              "Invalid ref %s in %s", ref.getName(), project.getName());
+    try (TraceTimer traceTimer =
+        newTimer("markHeadsAsUninteresting", Metadata.builder().branchName(forRef))) {
+      int i = 0;
+      for (Ref ref : allRefs().values()) {
+        if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
+            && ref.getObjectId() != null) {
+          try {
+            rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+            i++;
+          } catch (IOException e) {
+            logger.atWarning().withCause(e).log(
+                "Invalid ref %s in %s", ref.getName(), project.getName());
+          }
         }
       }
+      logger.atFine().log("Marked %d heads as uninteresting", i);
     }
-    logger.atFine().log("Marked %d heads as uninteresting", i);
   }
 
   private static boolean isValidChangeId(String idStr) {
@@ -2394,12 +2471,16 @@
   }
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
-    return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+    try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
+      return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+    }
   }
 
   private ChangeLookup lookupByCommit(RevCommit c) {
-    return new ChangeLookup(
-        c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+    try (TraceTimer traceTimer = newTimer("lookupByCommit")) {
+      return new ChangeLookup(
+          c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+    }
   }
 
   /** Represents a commit for which a Change should be created. */
@@ -2422,103 +2503,97 @@
     }
 
     private void setChangeId(int id) {
-      possiblyOverrideWorkInProgress();
+      try (TraceTimer traceTimer = newTimer(CreateRequest.class, "setChangeId")) {
+        changeId = Change.id(id);
+        ins =
+            changeInserterFactory
+                .create(changeId, commit, refName)
+                .setTopic(magicBranch.topic)
+                .setPrivate(setChangeAsPrivate)
+                .setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges())
+                // Changes already validated in validateNewCommits.
+                .setValidate(false);
 
-      changeId = new Change.Id(id);
-      ins =
-          changeInserterFactory
-              .create(changeId, commit, refName)
-              .setTopic(magicBranch.topic)
-              .setPrivate(setChangeAsPrivate)
-              .setWorkInProgress(magicBranch.workInProgress)
-              // Changes already validated in validateNewCommits.
-              .setValidate(false);
-
-      if (magicBranch.merged) {
-        ins.setStatus(Change.Status.MERGED);
+        if (magicBranch.merged) {
+          ins.setStatus(Change.Status.MERGED);
+        }
+        cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
+        if (receivePack.getPushCertificate() != null) {
+          ins.setPushCertificate(receivePack.getPushCertificate().toTextWithSignature());
+        }
       }
-      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
-      if (receivePack.getPushCertificate() != null) {
-        ins.setPushCertificate(receivePack.getPushCertificate().toTextWithSignature());
-      }
-    }
-
-    private void possiblyOverrideWorkInProgress() {
-      // When wip or ready explicitly provided, leave it as is.
-      if (magicBranch.workInProgress || magicBranch.ready) {
-        return;
-      }
-      magicBranch.workInProgress =
-          projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)
-              || firstNonNull(user.state().getGeneralPreferences().workInProgressByDefault, false);
     }
 
     private void addOps(BatchUpdate bu) throws RestApiException {
-      checkState(changeId != null, "must call setChangeId before addOps");
-      try {
-        RevWalk rw = receivePack.getRevWalk();
-        rw.parseBody(commit);
-        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
-        Account.Id me = user.getAccountId();
-        List<FooterLine> footerLines = commit.getFooterLines();
-        requireNonNull(magicBranch);
+      try (TraceTimer traceTimer = newTimer(CreateRequest.class, "addOps")) {
+        checkState(changeId != null, "must call setChangeId before addOps");
+        try {
+          RevWalk rw = receivePack.getRevWalk();
+          rw.parseBody(commit);
+          final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+          Account.Id me = user.getAccountId();
+          List<FooterLine> footerLines = commit.getFooterLines();
+          requireNonNull(magicBranch);
 
-        // TODO(dborowitz): Support reviewers by email from footers? Maybe not: kernel developers
-        // with AOSP accounts already complain about these notifications, and that would make it
-        // worse. Might be better to get rid of the feature entirely:
-        // https://groups.google.com/d/topic/repo-discuss/tIFxY7L4DXk/discussion
-        MailRecipients fromFooters = getRecipientsFromFooters(accountResolver, footerLines);
-        fromFooters.remove(me);
+          // TODO(dborowitz): Support reviewers by email from footers? Maybe not: kernel developers
+          // with AOSP accounts already complain about these notifications, and that would make it
+          // worse. Might be better to get rid of the feature entirely:
+          // https://groups.google.com/d/topic/repo-discuss/tIFxY7L4DXk/discussion
+          MailRecipients fromFooters = getRecipientsFromFooters(accountResolver, footerLines);
+          fromFooters.remove(me);
 
-        Map<String, Short> approvals = magicBranch.labels;
-        StringBuilder msg =
-            new StringBuilder(
-                ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.emptyMap()));
-        msg.append('.');
-        if (!Strings.isNullOrEmpty(magicBranch.message)) {
-          msg.append("\n").append(magicBranch.message);
-        }
+          Map<String, Short> approvals = magicBranch.labels;
+          StringBuilder msg =
+              new StringBuilder(
+                  ApprovalsUtil.renderMessageWithApprovals(
+                      psId.get(), approvals, Collections.emptyMap()));
+          msg.append('.');
+          if (!Strings.isNullOrEmpty(magicBranch.message)) {
+            msg.append("\n").append(magicBranch.message);
+          }
 
-        bu.setNotify(magicBranch.getNotifyForNewChange());
-        bu.insertChange(
-            ins.setReviewersAndCcsAsStrings(
-                    magicBranch.getCombinedReviewers(fromFooters),
-                    magicBranch.getCombinedCcs(fromFooters))
-                .setApprovals(approvals)
-                .setMessage(msg.toString())
-                .setRequestScopePropagator(requestScopePropagator)
-                .setSendMail(true)
-                .setPatchSetDescription(magicBranch.message));
-        if (!magicBranch.hashtags.isEmpty()) {
-          // Any change owner is allowed to add hashtags when creating a change.
-          bu.addOp(
-              changeId,
-              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
-        }
-        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+          bu.setNotify(magicBranch.getNotifyForNewChange());
+          bu.insertChange(
+              ins.setReviewersAndCcsAsStrings(
+                      magicBranch.getCombinedReviewers(fromFooters),
+                      magicBranch.getCombinedCcs(fromFooters))
+                  .setApprovals(approvals)
+                  .setMessage(msg.toString())
+                  .setRequestScopePropagator(requestScopePropagator)
+                  .setSendMail(true)
+                  .setPatchSetDescription(magicBranch.message));
+          if (!magicBranch.hashtags.isEmpty()) {
+            // Any change owner is allowed to add hashtags when creating a change.
+            bu.addOp(
+                changeId,
+                hashtagsFactory
+                    .create(new HashtagsInput(magicBranch.hashtags))
+                    .setFireEvent(false));
+          }
+          if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+            bu.addOp(
+                changeId,
+                new BatchUpdateOp() {
+                  @Override
+                  public boolean updateChange(ChangeContext ctx) {
+                    ctx.getUpdate(psId).setTopic(magicBranch.topic);
+                    return true;
+                  }
+                });
+          }
           bu.addOp(
               changeId,
               new BatchUpdateOp() {
                 @Override
                 public boolean updateChange(ChangeContext ctx) {
-                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
-                  return true;
+                  CreateRequest.this.change = ctx.getChange();
+                  return false;
                 }
               });
+          bu.addOp(changeId, new ChangeProgressOp(progress));
+        } catch (Exception e) {
+          throw asRestApiException(e);
         }
-        bu.addOp(
-            changeId,
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                CreateRequest.this.change = ctx.getChange();
-                return false;
-              }
-            });
-        bu.addOp(changeId, new ChangeProgressOp(progress));
-      } catch (Exception e) {
-        throw INSERT_EXCEPTION.apply(e);
       }
     }
   }
@@ -2526,67 +2601,88 @@
   private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
       throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
-    for (CreateRequest r : create) {
+    try (TraceTimer traceTimer = newTimer("submit")) {
+      Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+      for (CreateRequest r : create) {
+        requireNonNull(
+            r.change,
+            () -> String.format("cannot submit new change %s; op may not have run", r.changeId));
+        bySha.put(r.commit, r.change);
+      }
+      for (ReplaceRequest r : replace) {
+        bySha.put(r.newCommitId, r.notes.getChange());
+      }
+      Change tipChange = bySha.get(magicBranch.cmd.getNewId());
       requireNonNull(
-          r.change,
-          () -> String.format("cannot submit new change %s; op may not have run", r.changeId));
-      bySha.put(r.commit, r.change);
-    }
-    for (ReplaceRequest r : replace) {
-      bySha.put(r.newCommitId, r.notes.getChange());
-    }
-    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
-    requireNonNull(
-        tipChange,
-        () ->
-            String.format(
-                "tip of push does not correspond to a change; found these changes: %s", bySha));
-    logger.atFine().log(
-        "Processing submit with tip change %s (%s)", tipChange.getId(), magicBranch.cmd.getNewId());
-    try (MergeOp op = mergeOpProvider.get()) {
-      op.merge(tipChange, user, false, new SubmitInput(), false);
+          tipChange,
+          () ->
+              String.format(
+                  "tip of push does not correspond to a change; found these changes: %s", bySha));
+      logger.atFine().log(
+          "Processing submit with tip change %s (%s)",
+          tipChange.getId(), magicBranch.cmd.getNewId());
+      try (MergeOp op = mergeOpProvider.get()) {
+        SubmitInput submitInput = new SubmitInput();
+        submitInput.notify = magicBranch.notifyHandling;
+        submitInput.notifyDetails = new HashMap<>();
+        submitInput.notifyDetails.put(
+            RecipientType.TO,
+            new NotifyInfo(magicBranch.notifyTo.stream().map(Object::toString).collect(toList())));
+        submitInput.notifyDetails.put(
+            RecipientType.CC,
+            new NotifyInfo(magicBranch.notifyCc.stream().map(Object::toString).collect(toList())));
+        submitInput.notifyDetails.put(
+            RecipientType.BCC,
+            new NotifyInfo(magicBranch.notifyBcc.stream().map(Object::toString).collect(toList())));
+        op.merge(tipChange, user, false, submitInput, false);
+      }
     }
   }
 
   private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
-    try {
-      readChangesForReplace();
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validateNewPatchSet();
+    try (TraceTimer traceTimer =
+        newTimer(
+            "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
+      try {
+        readChangesForReplace();
+        for (ReplaceRequest req : replaceByChange.values()) {
+          if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+            req.validateNewPatchSet();
+          }
         }
+      } catch (StorageException err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot read database before replacement for project %s", project.getName());
+        rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
+      } catch (IOException | PermissionBackendException err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot read repository before replacement for project %s", project.getName());
+        rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
       }
-    } catch (StorageException err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot read database before replacement for project %s", project.getName());
-      rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
-    } catch (IOException | PermissionBackendException err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot read repository before replacement for project %s", project.getName());
-      rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
-    }
-    logger.atFine().log("Read %d changes to replace", replaceByChange.size());
+      logger.atFine().log("Read %d changes to replace", replaceByChange.size());
 
-    if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      // Cancel creations tied to refs/for/ or refs/drafts/ command.
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+      if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+        // Cancel creations tied to refs/for/ command.
+        for (ReplaceRequest req : replaceByChange.values()) {
+          if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+            req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+          }
+        }
+        for (CreateRequest req : newChanges) {
           req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
         }
       }
-      for (CreateRequest req : newChanges) {
-        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
-      }
     }
   }
 
   private void readChangesForReplace() {
-    Collection<ChangeNotes> allNotes =
-        notesFactory.create(
-            replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
-    for (ChangeNotes notes : allNotes) {
-      replaceByChange.get(notes.getChangeId()).notes = notes;
+    try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
+      Collection<ChangeNotes> allNotes =
+          notesFactory.create(
+              replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
+      for (ChangeNotes notes : allNotes) {
+        replaceByChange.get(notes.getChangeId()).notes = notes;
+      }
     }
   }
 
@@ -2648,24 +2744,26 @@
      * @throws PermissionBackendException
      */
     boolean validateNewPatchSet() throws IOException, PermissionBackendException {
-      if (!validateNewPatchSetNoteDb()) {
-        return false;
-      }
-      sameTreeWarning();
-
-      if (magicBranch != null) {
-        validateMagicBranchWipStatusChange();
-        if (inputCommand.getResult() != NOT_ATTEMPTED) {
+      try (TraceTimer traceTimer = newTimer("validateNewPatchSet")) {
+        if (!validateNewPatchSetNoteDb()) {
           return false;
         }
+        sameTreeWarning();
 
-        if (magicBranch.edit || magicBranch.draft) {
-          return newEdit();
+        if (magicBranch != null) {
+          validateMagicBranchWipStatusChange();
+          if (inputCommand.getResult() != NOT_ATTEMPTED) {
+            return false;
+          }
+
+          if (magicBranch.edit) {
+            return newEdit();
+          }
         }
-      }
 
-      newPatchSet();
-      return true;
+        newPatchSet();
+        return true;
+      }
     }
 
     boolean validateNewPatchSetForAutoClose() throws IOException, PermissionBackendException {
@@ -2679,59 +2777,63 @@
 
     /** Validates the new PS against permissions and notedb status. */
     private boolean validateNewPatchSetNoteDb() throws IOException, PermissionBackendException {
-      if (notes == null) {
-        reject(inputCommand, "change " + ontoChange + " not found");
-        return false;
-      }
-
-      Change change = notes.getChange();
-      priorPatchSet = change.currentPatchSetId();
-      if (!revisions.containsValue(priorPatchSet)) {
-        reject(inputCommand, "change " + ontoChange + " missing revisions");
-        return false;
-      }
-
-      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-
-      // Not allowed to create a new patch set if the current patch set is locked.
-      if (psUtil.isPatchSetLocked(notes)) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
-
-      try {
-        permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
-      } catch (AuthException no) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
-
-      if (change.isClosed()) {
-        reject(inputCommand, "change " + ontoChange + " closed");
-        return false;
-      } else if (revisions.containsKey(newCommit)) {
-        reject(inputCommand, "commit already exists (in the change)");
-        return false;
-      }
-
-      for (Ref r : receivePack.getRepository().getRefDatabase().getRefsByPrefix("refs/changes")) {
-        if (r.getObjectId().equals(newCommit)) {
-          reject(inputCommand, "commit already exists (in the project)");
+      try (TraceTimer traceTimer = newTimer("validateNewPatchSetNoteDb")) {
+        if (notes == null) {
+          reject(inputCommand, "change " + ontoChange + " not found");
           return false;
         }
-      }
 
-      for (RevCommit prior : revisions.keySet()) {
-        // Don't allow a change to directly depend upon itself. This is a
-        // very common error due to users making a new commit rather than
-        // amending when trying to address review comments.
-        if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) {
-          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+        Change change = notes.getChange();
+        priorPatchSet = change.currentPatchSetId();
+        if (!revisions.containsValue(priorPatchSet)) {
+          reject(inputCommand, "change " + ontoChange + " missing revisions");
           return false;
         }
-      }
 
-      return true;
+        RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+
+        // Not allowed to create a new patch set if the current patch set is locked.
+        if (psUtil.isPatchSetLocked(notes)) {
+          reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+          return false;
+        }
+
+        try {
+          permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
+        } catch (AuthException no) {
+          reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+          return false;
+        }
+
+        if (change.isClosed()) {
+          reject(inputCommand, "change " + ontoChange + " closed");
+          return false;
+        } else if (revisions.containsKey(newCommit)) {
+          reject(inputCommand, "commit already exists (in the change)");
+          return false;
+        }
+
+        for (Ref r : receivePack.getRepository().getRefDatabase().getRefsByPrefix("refs/changes")) {
+          if (r.getObjectId().equals(newCommit)) {
+            reject(inputCommand, "commit already exists (in the project)");
+            return false;
+          }
+        }
+
+        try (TraceTimer traceTimer2 = newTimer("validateNewPatchSetNoteDb#isMergedInto")) {
+          for (RevCommit prior : revisions.keySet()) {
+            // Don't allow a change to directly depend upon itself. This is a
+            // very common error due to users making a new commit rather than
+            // amending when trying to address review comments.
+            if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) {
+              reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+              return false;
+            }
+          }
+        }
+
+        return true;
+      }
     }
 
     /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */
@@ -2760,39 +2862,41 @@
 
     /** prints a warning if the new PS has the same tree as the previous commit. */
     private void sameTreeWarning() throws IOException {
-      RevWalk rw = receivePack.getRevWalk();
-      RevCommit newCommit = rw.parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      try (TraceTimer traceTimer = newTimer("sameTreeWarning")) {
+        RevWalk rw = receivePack.getRevWalk();
+        RevCommit newCommit = rw.parseCommit(newCommitId);
+        RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
 
-      if (newCommit.getTree().equals(priorCommit.getTree())) {
-        rw.parseBody(newCommit);
-        rw.parseBody(priorCommit);
-        boolean messageEq =
-            Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
+        if (newCommit.getTree().equals(priorCommit.getTree())) {
+          rw.parseBody(newCommit);
+          rw.parseBody(priorCommit);
+          boolean messageEq =
+              Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
+          boolean parentsEq = parentsEqual(newCommit, priorCommit);
+          boolean authorEq = authorEqual(newCommit, priorCommit);
+          ObjectReader reader = receivePack.getRevWalk().getObjectReader();
 
-        if (messageEq && parentsEq && authorEq) {
-          addMessage(
-              String.format(
-                  "warning: no changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
-        } else {
-          StringBuilder msg = new StringBuilder();
-          msg.append("warning: ").append(reader.abbreviate(newCommit).name());
-          msg.append(":");
-          msg.append(" no files changed");
-          if (!authorEq) {
-            msg.append(", author changed");
+          if (messageEq && parentsEq && authorEq) {
+            addMessage(
+                String.format(
+                    "warning: no changes between prior commit %s and new commit %s",
+                    abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader)));
+          } else {
+            StringBuilder msg = new StringBuilder();
+            msg.append("warning: ").append(abbreviateName(newCommit, reader));
+            msg.append(":");
+            msg.append(" no files changed");
+            if (!authorEq) {
+              msg.append(", author changed");
+            }
+            if (!messageEq) {
+              msg.append(", message updated");
+            }
+            if (!parentsEq) {
+              msg.append(", was rebased");
+            }
+            addMessage(msg.toString());
           }
-          if (!messageEq) {
-            msg.append(", message updated");
-          }
-          if (!parentsEq) {
-            msg.append(", was rebased");
-          }
-          addMessage(msg.toString());
         }
       }
     }
@@ -2802,33 +2906,36 @@
      * failure.
      */
     private boolean newEdit() {
-      psId = notes.getChange().currentPatchSetId();
-      Optional<ChangeEdit> edit;
+      try (TraceTimer traceTimer = newTimer("newEdit")) {
+        psId = notes.getChange().currentPatchSetId();
+        Optional<ChangeEdit> edit;
 
-      try {
-        edit = editUtil.byChange(notes, user);
-      } catch (AuthException | IOException e) {
-        logger.atSevere().withCause(e).log("Cannot retrieve edit");
-        return false;
-      }
+        try {
+          edit = editUtil.byChange(notes, user);
+        } catch (AuthException | IOException e) {
+          logger.atSevere().withCause(e).log("Cannot retrieve edit");
+          return false;
+        }
 
-      if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(psId)) {
-          // replace edit
-          cmd =
-              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+        if (edit.isPresent()) {
+          if (edit.get().getBasePatchSet().id().equals(psId)) {
+            // replace edit
+            cmd =
+                new ReceiveCommand(
+                    edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+          } else {
+            // delete old edit ref on rebase
+            prev =
+                new ReceiveCommand(
+                    edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
+            createEditCommand();
+          }
         } else {
-          // delete old edit ref on rebase
-          prev =
-              new ReceiveCommand(
-                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
           createEditCommand();
         }
-      } else {
-        createEditCommand();
-      }
 
-      return true;
+        return true;
+      }
     }
 
     /** Creates a ReceiveCommand for a new edit. */
@@ -2842,47 +2949,52 @@
 
     /** Updates 'this' to add a new patchset. */
     private void newPatchSet() throws IOException {
-      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-      psId =
-          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
-      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
+      try (TraceTimer traceTimer = newTimer("newPatchSet")) {
+        RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+        psId =
+            ChangeUtil.nextPatchSetIdFromAllRefsMap(
+                allRefs(), notes.getChange().currentPatchSetId());
+        info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
+        cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
+      }
     }
 
     void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
-        if (prev != null) {
-          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+      try (TraceTimer traceTimer = newTimer("addOps")) {
+        if (magicBranch != null && magicBranch.edit) {
+          bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
+          if (prev != null) {
+            bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+          }
+          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+          return;
         }
-        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
-        return;
-      }
-      RevWalk rw = receivePack.getRevWalk();
-      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
-      RevCommit newCommit = rw.parseCommit(newCommitId);
-      rw.parseBody(newCommit);
+        RevWalk rw = receivePack.getRevWalk();
+        // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+        RevCommit newCommit = rw.parseCommit(newCommitId);
+        rw.parseBody(newCommit);
 
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp =
-          replaceOpFactory
-              .create(
-                  projectState,
-                  notes.getChange().getDest(),
-                  checkMergedInto,
-                  checkMergedInto ? inputCommand.getNewId().name() : null,
-                  priorPatchSet,
-                  priorCommit,
-                  psId,
-                  newCommit,
-                  info,
-                  groups,
-                  magicBranch,
-                  receivePack.getPushCertificate())
-              .setRequestScopePropagator(requestScopePropagator);
-      bu.addOp(notes.getChangeId(), replaceOp);
-      if (progress != null) {
-        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+        RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+        replaceOp =
+            replaceOpFactory
+                .create(
+                    projectState,
+                    notes.getChange().getDest(),
+                    checkMergedInto,
+                    checkMergedInto ? inputCommand.getNewId().name() : null,
+                    priorPatchSet,
+                    priorCommit,
+                    psId,
+                    newCommit,
+                    info,
+                    groups,
+                    magicBranch,
+                    receivePack.getPushCertificate())
+                .setRequestScopePropagator(requestScopePropagator);
+        bu.addOp(notes.getChangeId(), replaceOp);
+        if (progress != null) {
+          bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+        }
       }
     }
 
@@ -2903,12 +3015,12 @@
 
     private void addOps(BatchUpdate bu) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
             public boolean updateChange(ChangeContext ctx) {
               PatchSet ps = psUtil.get(ctx.getNotes(), psId);
-              List<String> oldGroups = ps.getGroups();
+              List<String> oldGroups = ps.groups();
               if (oldGroups == null) {
                 if (groups == null) {
                   return false;
@@ -2916,7 +3028,7 @@
               } else if (sameGroups(oldGroups, groups)) {
                 return false;
               }
-              psUtil.setGroups(ctx.getUpdate(psId), ps, groups);
+              ctx.getUpdate(psId).setGroups(groups);
               return true;
             }
           });
@@ -2980,7 +3092,11 @@
   }
 
   private void initChangeRefMaps() {
-    if (refsByChange == null) {
+    if (refsByChange != null) {
+      return;
+    }
+
+    try (TraceTimer traceTimer = newTimer("initChangeRefMaps")) {
       int estRefsPerChange = 4;
       refsById = MultimapBuilder.hashKeys().arrayListValues().build();
       refsByChange =
@@ -2993,7 +3109,7 @@
           PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
           if (psId != null) {
             refsById.put(obj, ref);
-            refsByChange.put(psId.getParentKey(), ref);
+            refsByChange.put(psId.changeId(), ref);
           }
         }
       }
@@ -3039,17 +3155,19 @@
   // Run RefValidators on the command. If any validator fails, the command status is set to
   // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
-    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+    try (TraceTimer traceTimer = newTimer("validRefOperation")) {
+      RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
-    try {
-      messages.addAll(refValidators.validateForRefOperation());
-    } catch (RefOperationValidationException e) {
-      messages.addAll(Lists.newArrayList(e.getMessages()));
-      reject(cmd, e.getMessage());
-      return false;
+      try {
+        messages.addAll(refValidators.validateForRefOperation());
+      } catch (RefOperationValidationException e) {
+        messages.addAll(e.getMessages());
+        reject(cmd, e.getMessage());
+        return false;
+      }
+
+      return true;
     }
-
-    return true;
   }
 
   /**
@@ -3057,184 +3175,194 @@
    *
    * <p>On validation failure, the command is rejected.
    */
-  private void validateRegularPushCommits(Branch.NameKey branch, ReceiveCommand cmd)
+  private void validateRegularPushCommits(BranchNameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
-    boolean skipValidation =
-        !RefNames.REFS_CONFIG.equals(cmd.getRefName())
-            && !(MagicBranch.isMagicBranch(cmd.getRefName())
-                || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
-            && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION);
-    if (skipValidation) {
-      if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
-        reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
-        return;
-      }
-
-      Optional<AuthException> err =
-          checkRefPermission(permissions.ref(branch.get()), RefPermission.SKIP_VALIDATION);
-      if (err.isPresent()) {
-        rejectProhibited(cmd, err.get());
-        return;
-      }
-      if (!Iterables.isEmpty(rejectCommits)) {
-        reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
-      }
-    }
-
-    BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
-    RevWalk walk = receivePack.getRevWalk();
-    walk.reset();
-    walk.sort(RevSort.NONE);
-    try {
-      RevObject parsedObject = walk.parseAny(cmd.getNewId());
-      if (!(parsedObject instanceof RevCommit)) {
-        return;
-      }
-      ListMultimap<ObjectId, Ref> existing = changeRefsById();
-      walk.markStart((RevCommit) parsedObject);
-      markHeadsAsUninteresting(walk, cmd.getRefName());
-      int limit = receiveConfig.maxBatchCommits;
-      int n = 0;
-      for (RevCommit c; (c = walk.next()) != null; ) {
-        // Even if skipValidation is set, we still get here when at least one plugin
-        // commit validator requires to validate all commits. In this case, however,
-        // we don't need to check the commit limit.
-        if (++n > limit && !skipValidation) {
-          logger.atFine().log("Number of new commits exceeds limit of %d", limit);
-          reject(
-              cmd,
-              String.format(
-                  "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
+    try (TraceTimer traceTimer =
+        newTimer("validateRegularPushCommits", Metadata.builder().branchName(branch.branch()))) {
+      boolean skipValidation =
+          !RefNames.REFS_CONFIG.equals(cmd.getRefName())
+              && !(MagicBranch.isMagicBranch(cmd.getRefName())
+                  || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
+              && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION);
+      if (skipValidation) {
+        if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+          reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
           return;
         }
-        if (existing.keySet().contains(c)) {
-          continue;
-        }
 
-        if (!validator.validCommit(
-            walk.getObjectReader(), cmd, c, false, messages, rejectCommits, null, skipValidation)) {
-          break;
+        Optional<AuthException> err =
+            checkRefPermission(permissions.ref(branch.branch()), RefPermission.SKIP_VALIDATION);
+        if (err.isPresent()) {
+          rejectProhibited(cmd, err.get());
+          return;
+        }
+        if (!Iterables.isEmpty(rejectCommits)) {
+          reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
         }
       }
-      logger.atFine().log("Validated %d new commits", n);
-    } catch (IOException err) {
-      cmd.setResult(REJECTED_MISSING_OBJECT);
-      logger.atSevere().withCause(err).log("Invalid pack upload; one or more objects weren't sent");
+
+      BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
+      RevWalk walk = receivePack.getRevWalk();
+      walk.reset();
+      walk.sort(RevSort.NONE);
+      try {
+        RevObject parsedObject = walk.parseAny(cmd.getNewId());
+        if (!(parsedObject instanceof RevCommit)) {
+          return;
+        }
+        ListMultimap<ObjectId, Ref> existing = changeRefsById();
+        walk.markStart((RevCommit) parsedObject);
+        markHeadsAsUninteresting(walk, cmd.getRefName());
+        int limit = receiveConfig.maxBatchCommits;
+        int n = 0;
+        for (RevCommit c; (c = walk.next()) != null; ) {
+          // Even if skipValidation is set, we still get here when at least one plugin
+          // commit validator requires to validate all commits. In this case, however,
+          // we don't need to check the commit limit.
+          if (++n > limit && !skipValidation) {
+            logger.atFine().log("Number of new commits exceeds limit of %d", limit);
+            reject(
+                cmd,
+                String.format(
+                    "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
+            return;
+          }
+          if (existing.keySet().contains(c)) {
+            continue;
+          }
+
+          BranchCommitValidator.Result validationResult =
+              validator.validateCommit(
+                  walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+          messages.addAll(validationResult.messages());
+          if (!validationResult.isValid()) {
+            break;
+          }
+        }
+        logger.atFine().log("Validated %d new commits", n);
+      } catch (IOException err) {
+        cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(err).log(
+            "Invalid pack upload; one or more objects weren't sent");
+      }
     }
   }
 
   private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
-    logger.atFine().log("Starting auto-closing of changes");
-    String refName = cmd.getRefName();
-    Set<Change.Id> ids = new HashSet<>();
+    try (TraceTimer traceTimer = newTimer("autoCloseChanges")) {
+      logger.atFine().log("Starting auto-closing of changes");
+      String refName = cmd.getRefName();
+      Set<Change.Id> ids = new HashSet<>();
 
-    // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // handleRegularCommands
-    try {
-      retryHelper.execute(
-          updateFactory -> {
-            try (BatchUpdate bu =
-                    updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
-                ObjectInserter ins = repo.newObjectInserter();
-                ObjectReader reader = ins.newReader();
-                RevWalk rw = new RevWalk(reader)) {
-              bu.setRepository(repo, rw, ins);
-              // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+      // TODO(dborowitz): Combine this BatchUpdate with the main one in
+      // handleRegularCommands
+      try {
+        retryHelper.execute(
+            updateFactory -> {
+              try (BatchUpdate bu =
+                      updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                  ObjectInserter ins = repo.newObjectInserter();
+                  ObjectReader reader = ins.newReader();
+                  RevWalk rw = new RevWalk(reader)) {
+                bu.setRepository(repo, rw, ins);
+                // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
-              RevCommit newTip = rw.parseCommit(cmd.getNewId());
-              Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
+                RevCommit newTip = rw.parseCommit(cmd.getNewId());
+                BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
 
-              rw.reset();
-              rw.sort(RevSort.REVERSE);
-              rw.markStart(newTip);
-              if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-                rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-              }
+                rw.reset();
+                rw.sort(RevSort.REVERSE);
+                rw.markStart(newTip);
+                if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+                  rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+                }
 
-              ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
-              Map<Change.Key, ChangeNotes> byKey = null;
-              List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+                ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
+                Map<Change.Key, ChangeNotes> byKey = null;
+                List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
-              int existingPatchSets = 0;
-              int newPatchSets = 0;
-              COMMIT:
-              for (RevCommit c; (c = rw.next()) != null; ) {
-                rw.parseBody(c);
+                int existingPatchSets = 0;
+                int newPatchSets = 0;
+                COMMIT:
+                for (RevCommit c; (c = rw.next()) != null; ) {
+                  rw.parseBody(c);
 
-                for (Ref ref : byCommit.get(c.copy())) {
-                  PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-                  Optional<ChangeNotes> notes = getChangeNotes(psId.getParentKey());
-                  if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
-                    existingPatchSets++;
-                    bu.addOp(notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
-                    bu.addOp(
-                        psId.getParentKey(),
-                        mergedByPushOpFactory.create(
-                            requestScopePropagator, psId, refName, newTip.getId().getName()));
-                    continue COMMIT;
+                  for (Ref ref : byCommit.get(c.copy())) {
+                    PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                    Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
+                    if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
+                      existingPatchSets++;
+                      bu.addOp(notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
+                      bu.addOp(
+                          psId.changeId(),
+                          mergedByPushOpFactory.create(
+                              requestScopePropagator, psId, refName, newTip.getId().getName()));
+                      continue COMMIT;
+                    }
+                  }
+
+                  for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+                    if (byKey == null) {
+                      byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
+                    }
+
+                    ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
+                    if (onto != null) {
+                      newPatchSets++;
+                      // Hold onto this until we're done with the walk, as the call to
+                      // req.validate below calls isMergedInto which resets the walk.
+                      ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+                      req.notes = onto;
+                      replaceAndClose.add(req);
+                      continue COMMIT;
+                    }
                   }
                 }
 
-                for (String changeId : c.getFooterLines(CHANGE_ID)) {
-                  if (byKey == null) {
-                    byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
+                for (ReplaceRequest req : replaceAndClose) {
+                  Change.Id id = req.notes.getChangeId();
+                  if (!req.validateNewPatchSetForAutoClose()) {
+                    logger.atFine().log("Not closing %s because validation failed", id);
+                    continue;
                   }
-
-                  ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
-                  if (onto != null) {
-                    newPatchSets++;
-                    // Hold onto this until we're done with the walk, as the call to
-                    // req.validate below calls isMergedInto which resets the walk.
-                    ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-                    req.notes = onto;
-                    replaceAndClose.add(req);
-                    continue COMMIT;
-                  }
+                  req.addOps(bu, null);
+                  bu.addOp(id, setPrivateOpFactory.create(false, null));
+                  bu.addOp(
+                      id,
+                      mergedByPushOpFactory
+                          .create(
+                              requestScopePropagator, req.psId, refName, newTip.getId().getName())
+                          .setPatchSetProvider(req.replaceOp::getPatchSet));
+                  bu.addOp(id, new ChangeProgressOp(progress));
+                  ids.add(id);
                 }
+
+                logger.atFine().log(
+                    "Auto-closing %d changes with existing patch sets and %d with new patch sets",
+                    existingPatchSets, newPatchSets);
+                bu.execute();
+              } catch (IOException | StorageException | PermissionBackendException e) {
+                logger.atSevere().withCause(e).log("Failed to auto-close changes");
+                return null;
               }
 
-              for (ReplaceRequest req : replaceAndClose) {
-                Change.Id id = req.notes.getChangeId();
-                if (!req.validateNewPatchSetForAutoClose()) {
-                  logger.atFine().log("Not closing %s because validation failed", id);
-                  continue;
-                }
-                req.addOps(bu, null);
-                bu.addOp(id, setPrivateOpFactory.create(false, null));
-                bu.addOp(
-                    id,
-                    mergedByPushOpFactory
-                        .create(requestScopePropagator, req.psId, refName, newTip.getId().getName())
-                        .setPatchSetProvider(req.replaceOp::getPatchSet));
-                bu.addOp(id, new ChangeProgressOp(progress));
-                ids.add(id);
-              }
+              // If we are here, we didn't throw UpdateException. Record the result.
+              // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id doesn't
+              // fit into TreeSet.
+              ids.stream().forEach(id -> resultChangeIds.add(ResultChangeIds.Key.AUTOCLOSED, id));
 
-              logger.atFine().log(
-                  "Auto-closing %s changes with existing patch sets and %s with new patch sets",
-                  existingPatchSets, newPatchSets);
-              bu.execute();
-            } catch (IOException | StorageException | PermissionBackendException e) {
-              logger.atSevere().withCause(e).log("Failed to auto-close changes");
               return null;
-            }
-
-            // If we are here, we didn't throw UpdateException. Record the result.
-            // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id doesn't
-            // fit into TreeSet.
-            ids.stream().forEach(id -> resultChangeIds.add(ResultChangeIds.Key.AUTOCLOSED, id));
-
-            return null;
-          },
-          // Use a multiple of the default timeout to account for inner retries that may otherwise
-          // eat up the whole timeout so that no time is left to retry this outer action.
-          RetryHelper.options()
-              .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
-              .build());
-    } catch (RestApiException e) {
-      logger.atSevere().withCause(e).log("Can't insert patchset");
-    } catch (UpdateException e) {
-      logger.atSevere().withCause(e).log("Failed to auto-close changes");
+            },
+            // Use a multiple of the default timeout to account for inner retries that may otherwise
+            // eat up the whole timeout so that no time is left to retry this outer action.
+            RetryHelper.options()
+                .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
+                .build());
+      } catch (RestApiException e) {
+        logger.atSevere().withCause(e).log("Can't insert patchset");
+      } catch (UpdateException e) {
+        logger.atSevere().withCause(e).log("Failed to auto-close changes");
+      }
     }
   }
 
@@ -3247,7 +3375,7 @@
   }
 
   private <T> T executeIndexQuery(Action<T> action) {
-    try {
+    try (TraceTimer traceTimer = newTimer("executeIndexQuery")) {
       return retryHelper.execute(
           ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
     } catch (Exception e) {
@@ -3256,16 +3384,19 @@
     }
   }
 
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch) {
-    Map<Change.Key, ChangeNotes> r = new HashMap<>();
-    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      try {
-        r.put(cd.change().getKey(), cd.notes());
-      } catch (NoSuchChangeException e) {
-        // Ignore deleted change
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(BranchNameKey branch) {
+    try (TraceTimer traceTimer =
+        newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) {
+      Map<Change.Key, ChangeNotes> r = new HashMap<>();
+      for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
+        try {
+          r.put(cd.change().getKey(), cd.notes());
+        } catch (NoSuchChangeException e) {
+          // Ignore deleted change
+        }
       }
+      return r;
     }
-    return r;
   }
 
   // allRefsWatcher hooks into the protocol negotation to get a list of all known refs.
@@ -3275,7 +3406,25 @@
     return allRefsWatcher.getAllRefs();
   }
 
+  private TraceTimer newTimer(String name) {
+    return newTimer(getClass(), name);
+  }
+
+  private TraceTimer newTimer(Class<?> clazz, String name) {
+    return newTimer(clazz, name, Metadata.builder());
+  }
+
+  private TraceTimer newTimer(String name, Metadata.Builder metadataBuilder) {
+    return newTimer(getClass(), name, metadataBuilder);
+  }
+
+  private TraceTimer newTimer(Class<?> clazz, String name, Metadata.Builder metadataBuilder) {
+    metadataBuilder.projectName(project.getName());
+    return TraceContext.newTimer(clazz.getSimpleName() + "#" + name, metadataBuilder.build());
+  }
+
   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/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index cfe06ea..83bf554 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -14,43 +14,55 @@
 
 package com.google.gerrit.server.git.receive;
 
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Maps;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
-/** Exposes only the non refs/changes/ reference names. */
+/**
+ * Exposes only the non refs/changes/ reference names and provide additional haves.
+ *
+ * <p>Negotiation on Git push is suboptimal in that it tends to send more objects to the server than
+ * it should. This results in increased latencies for {@code git push}.
+ *
+ * <p>Ref advertisement for Git pushes still works in a "the server speaks first fashion" as Git
+ * Protocol V2 only addressed fetches Therefore the server needs to send all available references.
+ * For large repositories, this can be in the tens of megabytes to send to the client. We therefore
+ * remove all refs in refs/changes/* to scale down that footprint. Trivially, this would increase
+ * the unnecessary objects that the client has to send to the server because the common ancestor it
+ * finds in negotiation might be further back in history.
+ *
+ * <p>To work around this, we advertise the last 32 changes in that repository as additional {@code
+ * .haves}. This is a heuristical approach that aims at scaling down the number of unnecessary
+ * objects that client sends to the server. Unnecessary here refers to objects that the server
+ * already has.
+ *
+ * <p>TODO(hiesel): Instrument this heuristic and proof its value.
+ */
 public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @VisibleForTesting
-  @AutoValue
-  public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
-
-    public abstract Set<ObjectId> additionalHaves();
-  }
-
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
 
@@ -66,31 +78,18 @@
         "ReceiveCommitsAdvertiseRefsHook cannot be used for UploadPack");
   }
 
-  @SuppressWarnings("deprecation")
   @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-    Result r = advertiseRefs(HookUtil.ensureAllRefsAdvertised(rp));
-    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+  public void advertiseRefs(ReceivePack rp) throws ServiceMayNotContinueException {
+    Map<String, Ref> advertisedRefs = HookUtil.ensureAllRefsAdvertised(rp);
+    advertisedRefs.keySet().stream()
+        .filter(ReceiveCommitsAdvertiseRefsHook::skip)
+        .collect(toImmutableList())
+        .forEach(r -> advertisedRefs.remove(r));
+    rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
   }
 
-  @VisibleForTesting
-  public Result advertiseRefs(Map<String, Ref> oldRefs) {
-    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
-    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
-    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
-      String name = e.getKey();
-      if (!skip(name)) {
-        r.put(name, e.getValue());
-      }
-      if (name.startsWith(RefNames.REFS_CHANGES)) {
-        allPatchSets.add(e.getValue().getObjectId());
-      }
-    }
-    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
-        r, advertiseOpenChanges(allPatchSets));
-  }
-
-  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
+  private Set<ObjectId> advertiseOpenChanges(Repository repo)
+      throws ServiceMayNotContinueException {
     // Advertise some recent open changes, in case a commit is based on one.
     int limit = 32;
     try {
@@ -109,15 +108,20 @@
               .byProjectOpen(projectName)) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
-          ObjectId id = ObjectId.fromString(ps.getRevision().get());
           // Ensure we actually observed a patch set ref pointing to this
           // object, in case the database is out of sync with the repo and the
           // object doesn't actually exist.
-          if (allPatchSets.contains(id)) {
-            r.add(id);
+          try {
+            Ref psRef = repo.getRefDatabase().exactRef(RefNames.patchSetRef(ps.id()));
+            if (psRef != null) {
+              r.add(ps.commitId());
+            }
+          } catch (IOException e) {
+            throw new ServiceMayNotContinueException(e);
           }
         }
       }
+
       return r;
     } catch (StorageException err) {
       logger.atSevere().withCause(err).log("Cannot list open changes of %s", projectName);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
new file mode 100644
index 0000000..d574466
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.git.UsersSelfAdvertiseRefsHook;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
+
+/**
+ * Helper to ensure that the chain for advertising refs is the same in tests and production code.
+ */
+public class ReceiveCommitsAdvertiseRefsHookChain {
+
+  /**
+   * Returns a single {@link AdvertiseRefsHook} that encompasses a chain of {@link
+   * AdvertiseRefsHook} to be used for advertising when processing a Git push.
+   */
+  public static AdvertiseRefsHook create(
+      AllRefsWatcher allRefsWatcher,
+      UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook,
+      AllUsersName allUsersName,
+      Provider<InternalChangeQuery> queryProvider,
+      Project.NameKey projectName) {
+    return create(
+        allRefsWatcher,
+        usersSelfAdvertiseRefsHook,
+        allUsersName,
+        queryProvider,
+        projectName,
+        false);
+  }
+
+  /**
+   * Returns a single {@link AdvertiseRefsHook} that encompasses a chain of {@link
+   * AdvertiseRefsHook} to be used for advertising when processing a Git push. Omits {@link
+   * HackPushNegotiateHook} as that does not advertise refs on it's own but adds {@code .have} based
+   * on history which is not relevant for the tests we have.
+   */
+  @VisibleForTesting
+  public static AdvertiseRefsHook createForTest(
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName, CurrentUser user) {
+    return create(
+        new AllRefsWatcher(),
+        new UsersSelfAdvertiseRefsHook(Providers.of(user)),
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        queryProvider,
+        projectName,
+        true);
+  }
+
+  private static AdvertiseRefsHook create(
+      AllRefsWatcher allRefsWatcher,
+      UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook,
+      AllUsersName allUsersName,
+      Provider<InternalChangeQuery> queryProvider,
+      Project.NameKey projectName,
+      boolean skipHackPushNegotiateHook) {
+    List<AdvertiseRefsHook> advHooks = new ArrayList<>();
+    advHooks.add(allRefsWatcher);
+    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
+    if (!skipHackPushNegotiateHook) {
+      advHooks.add(new HackPushNegotiateHook());
+    }
+    if (projectName.equals(allUsersName)) {
+      advHooks.add(usersSelfAdvertiseRefsHook);
+    }
+    return AdvertiseRefsHookChain.newChain(advHooks);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConfig.java b/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
index b1c9f13..cdbf310 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
@@ -27,7 +27,6 @@
 class ReceiveConfig {
   final boolean checkMagicRefs;
   final boolean checkReferencedObjectsAreReachable;
-  final boolean allowDrafts;
   final int maxBatchCommits;
   final boolean disablePrivateChanges;
   private final int systemMaxBatchChanges;
@@ -38,7 +37,6 @@
     checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
     checkReferencedObjectsAreReachable =
         config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
-    allowDrafts = config.getBoolean("change", null, "allowDrafts", false);
     maxBatchCommits = config.getInt("receive", null, "maxBatchCommits", 10000);
     systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
     disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java b/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
index 16cba53..24a89f7 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git.receive;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.common.collect.Maps;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 4d0d2c3..e95cf3b 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -29,19 +29,19 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -99,7 +99,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -132,7 +132,7 @@
   private final ReviewerAdder reviewerAdder;
 
   private final ProjectState projectState;
-  private final Branch.NameKey dest;
+  private final BranchNameKey dest;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -177,7 +177,7 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       @Assisted ProjectState projectState,
-      @Assisted Branch.NameKey dest,
+      @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -232,7 +232,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.get(), commit);
+      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -255,7 +255,7 @@
     }
     if (groups.isEmpty()) {
       PatchSet prevPs = psUtil.current(notes);
-      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.of();
+      groups = prevPs != null ? prevPs.groups() : ImmutableList.of();
     }
 
     ChangeData cd = changeDataFactory.create(ctx.getNotes());
@@ -295,7 +295,7 @@
       }
       if (shouldPublishComments()) {
         boolean workInProgress = change.isWorkInProgress();
-        if (magicBranch != null && magicBranch.workInProgress) {
+        if (magicBranch.workInProgress) {
           workInProgress = true;
         }
         comments = publishComments(ctx, workInProgress);
@@ -459,7 +459,7 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.getLabelId());
+        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         }
@@ -481,11 +481,7 @@
     change.setCurrentPatchSet(info);
 
     List<String> idList = commit.getFooterLines(CHANGE_ID);
-    if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commitId.name()));
-    } else {
-      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
-    }
+    change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
   private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress) {
@@ -548,7 +544,7 @@
       try {
         ReplacePatchSetSender cm =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        cm.setFrom(ctx.getAccount().getAccount().getId());
+        cm.setFrom(ctx.getAccount().account().id());
         cm.setPatchSet(newPatchSet, info);
         cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
         cm.setNotify(ctx.getNotify(notes.getChangeId()));
@@ -556,7 +552,7 @@
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::getAccountId))
+                        .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
         cm.addExtraCC(
             Streams.concat(
@@ -567,7 +563,7 @@
         cm.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.getId());
+            "Cannot send email for new patch set %s", newPatchSet.id());
       }
     }
 
diff --git a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
index e326141..805822c 100644
--- a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
+++ b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import java.util.ArrayList;
 import java.util.EnumMap;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/git/receive/testing/BUILD b/java/com/google/gerrit/server/git/receive/testing/BUILD
new file mode 100644
index 0000000..06407ae
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/testing/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+    ],
+)
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
new file mode 100644
index 0000000..c54ab25
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.RefAdvertiser;
+
+/** Helper to collect advertised refs and additonal haves and verify them in tests. */
+public class TestRefAdvertiser extends RefAdvertiser {
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+
+    public abstract Set<ObjectId> additionalHaves();
+
+    public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
+      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+    }
+  }
+
+  private final Map<String, Ref> advertisedRefs;
+  private final Set<ObjectId> additionalHaves;
+  private final Repository repo;
+
+  public TestRefAdvertiser(Repository repo) {
+    advertisedRefs = new HashMap<>();
+    additionalHaves = new HashSet<>();
+    this.repo = repo;
+  }
+
+  @Override
+  protected void writeOne(CharSequence line) throws IOException {
+    List<String> lineParts =
+        StreamSupport.stream(Splitter.on(' ').split(line).spliterator(), false)
+            .map(String::trim)
+            .collect(toImmutableList());
+    if (".have".equals(lineParts.get(1))) {
+      additionalHaves.add(ObjectId.fromString(lineParts.get(0)));
+    } else {
+      ObjectId id = ObjectId.fromString(lineParts.get(0));
+      Ref ref =
+          repo.getRefDatabase().getRefs().stream()
+              .filter(r -> r.getObjectId().equals(id))
+              .findAny()
+              .orElseThrow(
+                  () ->
+                      new RuntimeException(
+                          line.toString() + " does not conform to expected pattern"));
+      advertisedRefs.put(lineParts.get(1), ref);
+    }
+  }
+
+  @Override
+  protected void end() {}
+
+  public Result result() {
+    return Result.create(advertisedRefs, additionalHaves);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 08870d3..c6af49c 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountConfig;
 import com.google.gerrit.server.account.AccountProperties;
@@ -87,10 +87,10 @@
       messages.add("cannot deactivate own account");
     }
 
-    String newPreferredEmail = newAccount.get().getPreferredEmail();
+    String newPreferredEmail = newAccount.get().preferredEmail();
     if (newPreferredEmail != null
         && (!oldAccount.isPresent()
-            || !newPreferredEmail.equals(oldAccount.get().getPreferredEmail()))) {
+            || !newPreferredEmail.equals(oldAccount.get().preferredEmail()))) {
       if (!emailValidator.isValid(newPreferredEmail)) {
         messages.add(
             String.format(
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index ff21b2d..6773d29 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.git.validators;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
+import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -25,14 +25,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -127,7 +127,7 @@
 
     public CommitValidators forReceiveCommits(
         PermissionBackend.ForProject forProject,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         NoteMap rejectCommits,
@@ -135,8 +135,8 @@
         @Nullable Change change,
         boolean skipValidation)
         throws IOException {
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
@@ -164,21 +164,21 @@
 
     public CommitValidators forGerritCommits(
         PermissionBackend.ForProject forProject,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())))
+          .add(new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())))
           .add(
               new ChangeIdValidator(
                   projectState,
@@ -196,7 +196,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        PermissionBackend.ForProject forProject, Branch.NameKey branch, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user)
         throws IOException {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
@@ -211,11 +211,11 @@
       //    discuss what to do about it.
       //  - Plugin validators may do things like require certain commit message
       //    formats, so we play it safe and exclude them.
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
-          .add(new ProjectStateValidationListener(projectCache.checkedGet(branch.getParentKey())))
+          .add(new ProjectStateValidationListener(projectCache.checkedGet(branch.project())))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
           .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()));
       return new CommitValidators(validators.build());
@@ -395,7 +395,7 @@
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final ProjectConfig.Factory projectConfigFactory;
-    private final Branch.NameKey branch;
+    private final BranchNameKey branch;
     private final IdentifiedUser user;
     private final RevWalk rw;
     private final AllUsersName allUsers;
@@ -403,7 +403,7 @@
 
     public ConfigValidator(
         ProjectConfig.Factory projectConfigFactory,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         RevWalk rw,
         AllUsersName allUsers,
@@ -419,7 +419,7 @@
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      if (REFS_CONFIG.equals(branch.get())) {
+      if (REFS_CONFIG.equals(branch.branch())) {
         List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 6edd04e..b47d7d6 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.ProjectState;
@@ -44,7 +44,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 75be8f3..e17e129 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -17,16 +17,16 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -80,7 +80,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
@@ -156,11 +156,11 @@
         final Repository repo,
         final CodeReviewCommit commit,
         final ProjectState destProject,
-        final Branch.NameKey destBranch,
+        final BranchNameKey destBranch,
         final PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
+      if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
         final Project.NameKey newParent;
         try {
           ProjectConfig cfg = projectConfigFactory.create(destProject.getNameKey());
@@ -251,7 +251,7 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
@@ -285,18 +285,17 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      Account.Id accountId = Account.Id.fromRef(destBranch.get());
+      Account.Id accountId = Account.Id.fromRef(destBranch.branch());
       if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
         return;
       }
 
       ChangeData cd =
-          changeDataFactory.create(
-              destProject.getProject().getNameKey(), patchSetId.getParentKey());
+          changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
       try {
         if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) {
           return;
@@ -336,13 +335,13 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
       // Groups are stored inside the 'All-Users' repository.
       if (!allUsersName.equals(destProject.getNameKey())
-          || !RefNames.isGroupRef(destBranch.get())) {
+          || !RefNames.isGroupRef(destBranch.branch())) {
         return;
       }
 
diff --git a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index 308fdc0..432dda3 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -17,8 +17,8 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
diff --git a/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 409240e..6faa7af 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.submit.IntegrationException;
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
index 9eaf2d2..d27cc38 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
@@ -14,27 +14,28 @@
 
 package com.google.gerrit.server.git.validators;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
 
 public class RefOperationValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final Iterable<ValidationMessage> messages;
+  private final ImmutableList<ValidationMessage> messages;
 
-  public RefOperationValidationException(String reason, Iterable<ValidationMessage> messages) {
+  public RefOperationValidationException(String reason, ImmutableList<ValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
 
-  public Iterable<ValidationMessage> getMessages() {
+  public ImmutableList<ValidationMessage> getMessages() {
     return messages;
   }
 
   @Override
   public String getMessage() {
-    StringBuilder msg = new StringBuilder(super.getMessage());
-    for (ValidationMessage error : messages) {
-      msg.append("\n").append(error.getMessage());
-    }
-    return msg.toString();
+    return messages.stream()
+        .map(ValidationMessage::getMessage)
+        .collect(joining("\n", super.getMessage() + "\n", ""));
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 3c0208c..e9734a3 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -13,14 +13,14 @@
 // limitations under the License.
 package com.google.gerrit.server.git.validators;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.events.RefReceivedEvent;
@@ -39,8 +39,6 @@
 public class RefOperationValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
-
   public interface Factory {
     RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
   }
@@ -93,22 +91,15 @@
     return messages;
   }
 
-  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
+  private void throwException(List<ValidationMessage> messages, RefReceivedEvent event)
       throws RefOperationValidationException {
-    Iterable<ValidationMessage> errors = Iterables.filter(messages, GET_ERRORS);
     String header =
         String.format(
             "Ref \"%s\" %S in project %s validation failed",
             event.command.getRefName(), event.command.getType(), event.project.getName());
     logger.atSevere().log(header);
-    throw new RefOperationValidationException(header, errors);
-  }
-
-  private static class GetErrorMessages implements Predicate<ValidationMessage> {
-    @Override
-    public boolean apply(ValidationMessage input) {
-      return input.isError();
-    }
+    throw new RefOperationValidationException(
+        header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
   private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
diff --git a/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
index 13065e4..8f1d5e4 100644
--- a/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.validators.ValidationException;
 import java.util.Collection;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/git/validators/UploadValidators.java b/java/com/google/gerrit/server/git/validators/UploadValidators.java
index 2595283..6847a28 100644
--- a/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
index 4c02ada..30e5d3c 100644
--- a/java/com/google/gerrit/server/group/GroupAuditService.java
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.AuditEvent;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 5fe3e8e..a54f465 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
@@ -81,7 +81,7 @@
    * @return the group, null if no group is found for the given group ID
    */
   public GroupDescription.Basic parseId(String id) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(id);
     if (groupBackend.handles(uuid)) {
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/server/group/InternalGroup.java
index 7828586..639cd7a 100644
--- a/java/com/google/gerrit/server/group/InternalGroup.java
+++ b/java/com/google/gerrit/server/group/InternalGroup.java
@@ -17,8 +17,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.io.Serializable;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 1d2252d..c70c8bf 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index 2a9538d..b2d9632 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index a33e96b..ceea2dc 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.group;
 
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.inject.TypeLiteral;
 
 public class SubgroupResource extends GroupResource {
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 85c1e73..7821a01 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -23,7 +23,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -56,19 +56,19 @@
 
   /** Common UUID assigned to the "Anonymous Users" group. */
   public static final AccountGroup.UUID ANONYMOUS_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
 
   /** Common UUID assigned to the "Registered Users" group. */
   public static final AccountGroup.UUID REGISTERED_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Registered-Users");
 
   /** Common UUID assigned to the "Project Owners" placeholder group. */
   public static final AccountGroup.UUID PROJECT_OWNERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Project-Owners");
 
   /** Common UUID assigned to the "Change Owner" placeholder group. */
   public static final AccountGroup.UUID CHANGE_OWNER =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Change-Owner");
 
   private static final AccountGroup.UUID[] all = {
     ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
diff --git a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
index 508f5ef..ec4c0fc 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
@@ -20,8 +20,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
@@ -51,7 +51,7 @@
   }
 
   private static Optional<Account> getAccount(AccountCache accountCache, Account.Id accountId) {
-    return accountCache.get(accountId).map(AccountState::getAccount);
+    return accountCache.get(accountId).map(AccountState::account);
   }
 
   private static Optional<GroupDescription.Basic> getGroup(
@@ -72,7 +72,7 @@
   }
 
   private static Optional<Account> getAccount(ImmutableSet<Account> accounts, Account.Id id) {
-    return accounts.stream().filter(account -> account.getId().equals(id)).findAny();
+    return accounts.stream().filter(account -> account.id().equals(id)).findAny();
   }
 
   public static AuditLogFormatter createPartiallyWorkingFallBack() {
@@ -119,7 +119,7 @@
    * @return a {@code PersonIdent} which can be used for the author of a commit
    */
   public PersonIdent getParsableAuthorIdent(Account account, PersonIdent personIdent) {
-    return getParsableAuthorIdent(account.getName(), account.getId(), personIdent);
+    return getParsableAuthorIdent(account.getName(), account.id(), personIdent);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 106ee6b..d8f0a0f 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -14,18 +14,19 @@
 
 package com.google.gerrit.server.group.db;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroupByIdAudit;
+import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.NoteDbUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -49,12 +50,10 @@
 public class AuditLogReader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final String serverId;
   private final AllUsersName allUsersName;
 
   @Inject
-  public AuditLogReader(@GerritServerId String serverId, AllUsersName allUsersName) {
-    this.serverId = serverId;
+  public AuditLogReader(AllUsersName allUsersName) {
     this.allUsersName = allUsersName;
   }
 
@@ -69,70 +68,79 @@
 
   private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+    ListMultimap<MemberKey, AccountGroupMemberAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupMemberAudit> result = ImmutableList.builder();
+    List<AccountGroupMemberAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (Account.Id id : pc.addedMembers()) {
         MemberKey key = MemberKey.create(groupId, id);
-        AccountGroupMemberAudit audit =
-            new AccountGroupMemberAudit(
-                new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+        AccountGroupMemberAudit.Builder audit =
+            AccountGroupMemberAudit.builder()
+                .memberId(id)
+                .groupId(groupId)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (Account.Id id : pc.removedMembers()) {
-        List<AccountGroupMemberAudit> adds = audits.get(MemberKey.create(groupId, id));
+        List<AccountGroupMemberAudit.Builder> adds = audits.get(MemberKey.create(groupId, id));
         if (!adds.isEmpty()) {
-          AccountGroupMemberAudit audit = adds.remove(0);
+          AccountGroupMemberAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Match old behavior of DbGroupAuditListener and add a "legacy" add/remove pair.
-          AccountGroupMemberAudit audit =
-              new AccountGroupMemberAudit(
-                  new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
-          audit.removedLegacy();
+          AccountGroupMemberAudit.Builder audit =
+              AccountGroupMemberAudit.builder()
+                  .groupId(groupId)
+                  .memberId(id)
+                  .addedOn(pc.when())
+                  .addedBy(pc.authorId())
+                  .removedLegacy();
           result.add(audit);
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupMemberAudit.Builder::build).collect(toImmutableList());
   }
 
-  public ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  public ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       Repository repo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
     return getSubgroupsAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
   }
 
-  private ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  private ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+    ListMultimap<SubgroupKey, AccountGroupByIdAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupByIdAud> result = ImmutableList.builder();
+    List<AccountGroupByIdAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (AccountGroup.UUID uuid : pc.addedSubgroups()) {
         SubgroupKey key = SubgroupKey.create(groupId, uuid);
-        AccountGroupByIdAud audit =
-            new AccountGroupByIdAud(
-                new AccountGroupByIdAud.Key(groupId, uuid, pc.when()), pc.authorId());
+        AccountGroupByIdAudit.Builder audit =
+            AccountGroupByIdAudit.builder()
+                .groupId(groupId)
+                .includeUuid(uuid)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (AccountGroup.UUID uuid : pc.removedSubgroups()) {
-        List<AccountGroupByIdAud> adds = audits.get(SubgroupKey.create(groupId, uuid));
+        List<AccountGroupByIdAudit.Builder> adds = audits.get(SubgroupKey.create(groupId, uuid));
         if (!adds.isEmpty()) {
-          AccountGroupByIdAud audit = adds.remove(0);
+          AccountGroupByIdAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Unlike members, DbGroupAuditListener didn't insert an add/remove pair here.
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
-    Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent(), serverId);
+    Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
       // Only report audit events from identified users, since this was a non-nullable field in
       // ReviewDb. May be revisited.
@@ -168,7 +176,7 @@
   private Optional<Account.Id> parseAccount(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
     Optional<Account.Id> result =
         Optional.ofNullable(RawParseUtils.parsePersonIdent(line.getValue()))
-            .flatMap(ident -> NoteDbUtil.parseIdent(ident, serverId));
+            .flatMap(ident -> NoteDbUtil.parseIdent(ident));
     if (!result.isPresent()) {
       logInvalid(uuid, c, line);
     }
@@ -182,7 +190,7 @@
       logInvalid(uuid, c, line);
       return Optional.empty();
     }
-    return Optional.of(new AccountGroup.UUID(ident.getEmailAddress()));
+    return Optional.of(AccountGroup.uuid(ident.getEmailAddress()));
   }
 
   private static void logInvalid(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 903b9c0..ab5c9b8 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -24,11 +24,11 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.group.InternalGroup;
@@ -411,12 +411,12 @@
   }
 
   private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
-    return readFromFile(MEMBERS_FILE, entry -> new Account.Id(Integer.parseInt(entry)));
+    return readFromFile(MEMBERS_FILE, entry -> Account.id(Integer.parseInt(entry)));
   }
 
   private ImmutableSet<AccountGroup.UUID> readSubgroups()
       throws IOException, ConfigInvalidException {
-    return readFromFile(SUBGROUPS_FILE, AccountGroup.UUID::new);
+    return readFromFile(SUBGROUPS_FILE, AccountGroup::uuid);
   }
 
   private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index f7a104d..81f5c7e 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.group.db;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -45,7 +45,7 @@
             String.format(
                 "ID of the group %s must not be negative, found %d", groupUuid.get(), id));
       }
-      group.setId(new AccountGroup.Id(id));
+      group.setId(AccountGroup.id(id));
     }
 
     @Override
@@ -77,7 +77,7 @@
       // the NoteDb migration converted such groups faithfully, so we need to be able to read them
       // back here.
       name = Strings.nullToEmpty(name);
-      group.setNameKey(new AccountGroup.NameKey(name));
+      group.setNameKey(AccountGroup.nameKey(name));
     }
 
     @Override
@@ -135,7 +135,7 @@
         throw new ConfigInvalidException(
             String.format("Owner UUID of the group %s must be defined", groupUuid.get()));
       }
-      group.setOwnerGroupUUID(new AccountGroup.UUID(ownerGroupUuid));
+      group.setOwnerGroupUUID(AccountGroup.uuid(ownerGroupUuid));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index eda7153..70d7a1a 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -29,10 +29,11 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Collection;
@@ -266,7 +267,7 @@
       RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
 
       for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
-        AccountGroup.NameKey nameKey = new AccountGroup.NameKey(e.getValue());
+        AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue());
         ObjectId noteKey = getNoteKey(nameKey);
         noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
       }
@@ -286,7 +287,7 @@
       cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
       ObjectId newId = inserter.insert(cb).copy();
 
-      ObjectId oldId = oldCommit != null ? oldCommit.copy() : ObjectId.zeroId();
+      ObjectId oldId = ObjectIds.copyOrZero(oldCommit);
       bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
     }
   }
@@ -442,7 +443,7 @@
       throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
     }
 
-    return new GroupReference(new AccountGroup.UUID(uuid), name);
+    return new GroupReference(AccountGroup.uuid(uuid), name);
   }
 
   private String getCommitMessage() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 37de011..2925cb3 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -17,9 +17,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroupByIdAudit;
+import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.InternalGroup;
@@ -152,7 +152,7 @@
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupByIdAud> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
+  public List<AccountGroupByIdAudit> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     return auditLogReader.getSubgroupsAudit(repo, groupUuid);
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
index 3afb793..8a6cd94 100644
--- a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
@@ -17,9 +17,9 @@
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.error;
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index c3ca60b..0414304 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -24,10 +24,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
@@ -163,7 +163,7 @@
           continue;
         }
 
-        ObjectId nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(gRef.getName()));
+        ObjectId nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(gRef.getName()));
         if (!Objects.equals(nameKey, note)) {
           result.problems.add(
               error("notename entry %s does not match name %s", note, gRef.getName()));
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index b450ab8..7f1ba6a 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -21,13 +21,13 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.update.RetryHelper;
@@ -265,7 +266,10 @@
       throws DuplicateKeyException, IOException, ConfigInvalidException {
     try (TraceTimer timer =
         TraceContext.newTimer(
-            "Creating group '%s'", groupUpdate.getName().orElseGet(groupCreation::getNameKey))) {
+            "Creating group",
+            Metadata.builder()
+                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
+                .build())) {
       InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
       evictCachesOnGroupCreation(createdGroup);
       dispatchAuditEventsOnGroupCreation(createdGroup);
@@ -285,7 +289,9 @@
    */
   public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    try (TraceTimer timer = TraceContext.newTimer("Updating group %s", groupUuid)) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
       Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
       if (!updatedOn.isPresent()) {
         updatedOn = Optional.of(TimeUtil.nowTs());
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
index bb21d62..8988547 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.group.db;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 /**
  * Definition of all properties necessary for a group creation.
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
index bff2952..9e6539a 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
@@ -16,8 +16,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import java.sql.Timestamp;
 import java.util.Optional;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index e002192..35ff513 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.DefaultQueueOp;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index 0cc45fd..8f33f98 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -9,7 +9,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib:jgit",
+        "//lib:jgit-junit",
     ],
 )
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 9b6d8de..fd61dff 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -8,10 +8,10 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 2f91394..8f20e92 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -18,17 +18,16 @@
 
 import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.DefaultSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-public class InternalGroupSubject extends Subject<InternalGroupSubject, InternalGroup> {
+public class InternalGroupSubject extends Subject {
 
   public static InternalGroupSubject assertThat(InternalGroup group) {
     return assertAbout(internalGroups()).that(group);
@@ -38,73 +37,65 @@
     return InternalGroupSubject::new;
   }
 
-  private InternalGroupSubject(FailureMetadata metadata, InternalGroup actual) {
-    super(metadata, actual);
+  private final InternalGroup group;
+
+  private InternalGroupSubject(FailureMetadata metadata, InternalGroup group) {
+    super(metadata, group);
+    this.group = group;
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
+  public ComparableSubject<AccountGroup.UUID> groupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("groupUuid()").that(group.getGroupUUID());
+    return check("getGroupUUID()").that(group.getGroupUUID());
   }
 
-  public ComparableSubject<?, AccountGroup.NameKey> nameKey() {
+  public ComparableSubject<AccountGroup.NameKey> nameKey() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("nameKey()").that(group.getNameKey());
+    return check("getNameKey()").that(group.getNameKey());
   }
 
   public StringSubject name() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("name()").that(group.getName());
+    return check("getName()").that(group.getName());
   }
 
-  public Subject<DefaultSubject, Object> id() {
+  public Subject id() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("id()").that(group.getId());
+    return check("getId()").that(group.getId());
   }
 
   public StringSubject description() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("description()").that(group.getDescription());
+    return check("getDescription()").that(group.getDescription());
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> ownerGroupUuid() {
+  public ComparableSubject<AccountGroup.UUID> ownerGroupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("ownerGroupUuid()").that(group.getOwnerGroupUUID());
+    return check("getOwnerGroupUUID()").that(group.getOwnerGroupUUID());
   }
 
   public BooleanSubject visibleToAll() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("visibleToAll()").that(group.isVisibleToAll());
+    return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
-  public ComparableSubject<?, Timestamp> createdOn() {
+  public ComparableSubject<Timestamp> createdOn() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("createdOn()").that(group.getCreatedOn());
+    return check("getCreatedOn()").that(group.getCreatedOn());
   }
 
   public IterableSubject members() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("members()").that(group.getMembers());
+    return check("getMembers()").that(group.getMembers());
   }
 
   public IterableSubject subgroups() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("subgroups()").that(group.getSubgroups());
+    return check("getSubgroups()").that(group.getSubgroups());
   }
 
-  public ComparableSubject<?, ObjectId> refState() {
+  public ComparableSubject<ObjectId> refState() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("refState()").that(group.getRefState());
+    return check("getRefState()").that(group.getRefState());
   }
 }
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 550b15c..51c7ca3 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
@@ -43,7 +43,7 @@
    */
   public GroupDescription.Basic create(String name) {
     requireNonNull(name);
-    return create(new AccountGroup.UUID(name.startsWith(PREFIX) ? name : PREFIX + name));
+    return create(AccountGroup.uuid(name.startsWith(PREFIX) ? name : PREFIX + name));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 352ea4b..995b4b6 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index;
 
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -44,9 +43,21 @@
   @Override
   protected void configure() {
     if (slave) {
-      bind(AccountIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ChangeIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ProjectIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+      bind(AccountIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ChangeIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ProjectIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
     } else {
       install(
           new FactoryModuleBuilder()
@@ -74,11 +85,6 @@
     }
   }
 
-  @SuppressWarnings("unused")
-  private static <T> T createDummyIndexFactory(Schema<?> schema) {
-    throw new UnsupportedOperationException();
-  }
-
   protected abstract Class<? extends AccountIndex> getAccountIndex();
 
   protected abstract Class<? extends ChangeIndex> getChangeIndex();
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index e7b892d..6db00f5 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -22,10 +22,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexRewriter;
@@ -71,11 +71,6 @@
  * (e.g. Lucene).
  */
 public class IndexModule extends LifecycleModule {
-  public enum IndexType {
-    LUCENE,
-    ELASTICSEARCH
-  }
-
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.of(
           AccountSchemaDefinitions.INSTANCE,
@@ -86,19 +81,8 @@
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
     Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    if (cfg != null) {
-      return cfg.getEnum("index", null, "type", IndexType.LUCENE);
-    }
-    return IndexType.LUCENE;
-  }
-
-  /** Type of secondary index. */
-  // TODO: stop relying on this method fostering error-prone string comparisons.
-  public static String getIndexType(@Nullable Config cfg) {
-    if (cfg != null) {
-      return cfg.getString("index", null, "type");
-    }
-    return IndexType.LUCENE.name();
+    String configValue = cfg != null ? cfg.getString("index", null, "type") : null;
+    return new IndexType(configValue);
   }
 
   private final int threads;
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 4b5cd49..a45777e 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -16,18 +16,21 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
@@ -56,17 +59,18 @@
     }
   }
 
-  public static Set<String> accountFields(QueryOptions opts) {
-    return accountFields(opts.fields());
+  public static Set<String> accountFields(QueryOptions opts, boolean useLegacyNumericFields) {
+    return accountFields(opts.fields(), useLegacyNumericFields);
   }
 
-  public static Set<String> accountFields(Set<String> fields) {
-    return fields.contains(AccountField.ID.getName())
-        ? fields
-        : Sets.union(fields, ImmutableSet.of(AccountField.ID.getName()));
+  public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
+    String idFieldName =
+        useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
+    return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
-  public static Set<String> changeFields(QueryOptions opts) {
+  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
+    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
@@ -75,10 +79,10 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
   }
 
   public static Set<String> groupFields(QueryOptions opts) {
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index f67a41d..0dd22ce 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -21,28 +21,33 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for accounts. */
 public class AccountField {
   public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.getAccount().getId().get());
+      integer("id").stored().build(a -> a.account().id().get());
+
+  public static final FieldDef<AccountState, String> ID_STR =
+      exact("id_str").stored().build(a -> String.valueOf(a.account().id().get()));
 
   /**
    * External IDs.
@@ -52,7 +57,7 @@
    */
   public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
       exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.getExternalIds(), id -> id.key().get()));
+          .buildRepeatable(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
 
   /**
    * Fuzzy prefix match on name and email parts.
@@ -67,7 +72,7 @@
   public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
       prefix("name")
           .buildRepeatable(
-              a -> getNameParts(a, Iterables.transform(a.getExternalIds(), ExternalId::email)));
+              a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
 
   /**
    * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
@@ -75,13 +80,13 @@
    */
   public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
       prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.getAccount().getPreferredEmail())));
+          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
 
   public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.getAccount().getFullName());
+      exact("full_name").build(a -> a.account().fullName());
 
   public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.getAccount().isActive() ? "1" : "0");
+      exact("inactive").build(a -> a.account().isActive() ? "1" : "0");
 
   /**
    * All emails (preferred email + secondary emails). Use this field only if the current user is
@@ -93,10 +98,10 @@
       prefix("email")
           .buildRepeatable(
               a ->
-                  FluentIterable.from(a.getExternalIds())
+                  FluentIterable.from(a.externalIds())
                       .transform(ExternalId::email)
-                      .append(Collections.singleton(a.getAccount().getPreferredEmail()))
-                      .filter(Predicates.notNull())
+                      .append(Collections.singleton(a.account().preferredEmail()))
+                      .filter(Objects::nonNull)
                       .transform(String::toLowerCase)
                       .toSet());
 
@@ -104,24 +109,24 @@
       prefix("preferredemail")
           .build(
               a -> {
-                String preferredEmail = a.getAccount().getPreferredEmail();
+                String preferredEmail = a.account().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
   public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
+      exact("preferredemail_exact").build(a -> a.account().preferredEmail());
 
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
+      timestamp("registered").build(a -> a.account().registeredOn());
 
   public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> a.getUserName().map(String::toLowerCase).orElse(""));
+      exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
 
   public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
       exact("watchedproject")
           .buildRepeatable(
               a ->
-                  FluentIterable.from(a.getProjectWatches().keySet())
+                  FluentIterable.from(a.projectWatches().keySet())
                       .transform(k -> k.project().get())
                       .toSet());
 
@@ -136,15 +141,19 @@
       storedOnly("ref_state")
           .buildRepeatable(
               a -> {
-                if (a.getAccount().getMetaId() == null) {
+                if (a.account().metaId() == null) {
                   return ImmutableList.of();
                 }
 
                 return ImmutableList.of(
                     RefState.create(
-                            RefNames.refsUsers(a.getAccount().getId()),
-                            ObjectId.fromString(a.getAccount().getMetaId()))
-                        .toByteArray(a.getAllUsersNameForIndexing()));
+                            RefNames.refsUsers(a.account().id()),
+                            ObjectId.fromString(a.account().metaId()))
+                        // We use the default AllUsers name to avoid having to pass around that
+                        // variable just for indexing.
+                        // This field is only used for staleness detection which will discover the
+                        // default name and replace it with the actually configured name.
+                        .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
   /**
@@ -157,13 +166,13 @@
       storedOnly("external_id_state")
           .buildRepeatable(
               a ->
-                  a.getExternalIds().stream()
+                  a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
-    String fullName = a.getAccount().getFullName();
+    String fullName = a.account().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
 
     // Additional values not currently added by getPersonParts.
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index 5c1b3dc..1838edf 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.index.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
 
@@ -27,6 +27,6 @@
 
   @Override
   default Predicate<AccountState> keyPredicate(Account.Id id) {
-    return AccountPredicates.id(id);
+    return AccountPredicates.id(getSchema(), id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
index 2a14f9b..eb1f784 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
index af23b52..3a34d47e 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 35b967c..643c249 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -16,22 +16,27 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.TooManyTermsInQueryException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import org.eclipse.jgit.util.MutableInteger;
 
 @Singleton
 public class AccountIndexRewriter implements IndexRewriter<AccountState> {
-
   private final AccountIndexCollection indexes;
+  private final IndexConfig config;
 
   @Inject
-  AccountIndexRewriter(AccountIndexCollection indexes) {
+  AccountIndexRewriter(AccountIndexCollection indexes, IndexConfig config) {
     this.indexes = indexes;
+    this.config = config;
   }
 
   @Override
@@ -39,6 +44,32 @@
       throws QueryParseException {
     AccountIndex index = indexes.getSearchIndex();
     requireNonNull(index, "no active search index configured for accounts");
+    validateMaxTermsInQuery(in);
     return new IndexedAccountQuery(index, in, opts);
   }
+
+  /**
+   * Validates whether a query has too many terms.
+   *
+   * @param predicate the predicate for which the leaf predicates should be counted
+   * @throws QueryParseException thrown if the query has too many terms
+   */
+  public void validateMaxTermsInQuery(Predicate<AccountState> predicate)
+      throws QueryParseException {
+    MutableInteger leafTerms = new MutableInteger();
+    validateMaxTermsInQuery(predicate, leafTerms);
+  }
+
+  private void validateMaxTermsInQuery(Predicate<AccountState> predicate, MutableInteger leafTerms)
+      throws TooManyTermsInQueryException {
+    if (!(predicate instanceof IndexPredicate)) {
+      if (++leafTerms.value > config.maxTerms()) {
+        throw new TooManyTermsInQueryException();
+      }
+    }
+
+    for (Predicate<AccountState> childPredicate : predicate.getChildren()) {
+      validateMaxTermsInQuery(childPredicate, leafTerms);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
index 7f4f295..8ced005 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.index.account;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 
 public interface AccountIndexer {
 
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 932e2c3..b908846 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -17,12 +17,13 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -90,13 +91,21 @@
       if (accountState.isPresent()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Replacing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Replacing account in index",
+                Metadata.builder()
+                    .accountId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.replace(accountState.get());
         }
       } else {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleting account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Deleting account in index",
+                Metadata.builder()
+                    .accountId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.delete(id);
         }
       }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index c41814f..157e290 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -49,7 +49,18 @@
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
 
   // Lucene index was changed to add additional fields for sorting.
-  static final Schema<AccountState> V10 = schema(V9);
+  @Deprecated static final Schema<AccountState> V10 = schema(V9);
+
+  // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
+  // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
+  // document id type is replaced with string document id type.
+  static final Schema<AccountState> V11 =
+      new Schema.Builder<AccountState>()
+          .add(V10)
+          .remove(AccountField.ID)
+          .add(AccountField.ID_STR)
+          .legacyNumericFields(false)
+          .build();
 
   public static final String NAME = "accounts";
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index acb7236..c1077b1 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -21,8 +21,8 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.SiteIndexer;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
diff --git a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index 8b9fa27..b3d961a0 100644
--- a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
@@ -23,7 +24,6 @@
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 
 public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 4664700..aad9527 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -22,16 +22,17 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.inject.Inject;
@@ -60,6 +61,12 @@
           AccountField.REF_STATE.getName(),
           AccountField.EXTERNAL_ID_STATE.getName());
 
+  public static final ImmutableSet<String> FIELDS2 =
+      ImmutableSet.of(
+          AccountField.ID_STR.getName(),
+          AccountField.REF_STATE.getName(),
+          AccountField.EXTERNAL_ID_STATE.getName());
+
   private final AccountIndexCollection indexes;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
@@ -92,8 +99,13 @@
       return false;
     }
 
+    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
-        i.getRaw(id, QueryOptions.create(indexConfig, 0, 1, IndexUtils.accountFields(FIELDS)));
+        i.getRaw(
+            id,
+            QueryOptions.create(
+                indexConfig, 0, 1, IndexUtils.accountFields(fields, useLegacyNumericFields)));
     if (!result.isPresent()) {
       // The document is missing in the index.
       try (Repository repo = repoManager.openRepository(allUsersName)) {
@@ -106,7 +118,11 @@
 
     for (Map.Entry<Project.NameKey, RefState> e :
         RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
-      try (Repository repo = repoManager.openRepository(e.getKey())) {
+      // Custom All-Users repository names are not indexed. Instead, the default name is used.
+      // Therefore, defer to the currently configured All-Users name.
+      Project.NameKey repoName =
+          e.getKey().get().equals(AllUsersNameProvider.DEFAULT) ? allUsersName : e.getKey();
+      try (Repository repo = repoManager.openRepository(repoName)) {
         if (!e.getValue().match(repo)) {
           // Ref was modified since the account was indexed.
           return true;
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 7082e08..4f23e88 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -23,12 +23,13 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.SiteIndexer;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
@@ -109,7 +110,7 @@
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
-        long size = estimateSize(repo);
+        int size = estimateSize(repo);
         changeCount += size;
         projects.add(new ProjectHolder(name, size));
       } catch (IOException e) {
@@ -127,15 +128,17 @@
     return indexAll(index, projects);
   }
 
-  private long estimateSize(Repository repo) throws IOException {
+  private int estimateSize(Repository repo) throws IOException {
     // Estimate size based on IDs that show up in ref names. This is not perfect, since patch set
     // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
     // the estimate is just used as a heuristic for sorting projects.
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
-        .map(r -> Change.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull)
-        .distinct()
-        .count();
+    long size =
+        repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
+            .map(r -> Change.Id.fromRef(r.getName()))
+            .filter(Objects::nonNull)
+            .distinct()
+            .count();
+    return Ints.saturatedCast(size);
   }
 
   private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 394761b..07e9b9e4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.intRange;
@@ -42,23 +43,22 @@
 import com.google.common.primitives.Longs;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
-import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -107,6 +107,9 @@
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       integer("legacy_id").stored().build(cd -> cd.getId().get());
 
+  public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
+      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
+
   /** Newer style Change-Id key. */
   public static final FieldDef<ChangeData, String> ID =
       prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
@@ -128,7 +131,7 @@
 
   /** Reference (aka branch) the change will submit onto. */
   public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
+      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
 
   /** Topic, a short annotation on the branch. */
   public static final FieldDef<ChangeData, String> EXACT_TOPIC =
@@ -237,7 +240,6 @@
     Set<String> r = new HashSet<>();
     for (String path : paths) {
       StringBuilder directory = new StringBuilder();
-      directory.append("");
       r.add(directory.toString());
       String nextPart = null;
       for (String part : s.split(path)) {
@@ -450,14 +452,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
       exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
 
-  private static Set<String> getRevisions(ChangeData cd) {
-    Set<String> revisions = new HashSet<>();
-    for (PatchSet ps : cd.patchSets()) {
-      if (ps.getRevision() != null) {
-        revisions.add(ps.getRevision().get());
-      }
-    }
-    return revisions;
+  private static ImmutableSet<String> getRevisions(ChangeData cd) {
+    return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
@@ -473,13 +469,12 @@
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
     for (PatchSetApproval a : cd.currentApprovals()) {
-      if (a.getValue() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId()));
-        if (owners && cd.change().getOwner().equals(a.getAccountId())) {
-          allApprovals.add(
-              formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+      if (a.value() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        if (owners && cd.change().getOwner().equals(a.accountId())) {
+          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
         }
-        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+        distinctApprovals.add(formatLabel(a.label(), a.value()));
       }
     }
     allApprovals.addAll(distinctApprovals);
@@ -669,8 +664,7 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd ->
-                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
+              cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
@@ -776,7 +770,7 @@
           SubmitRecord.Label srl = new SubmitRecord.Label();
           srl.label = label.label;
           srl.status = label.status;
-          srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null;
+          srl.appliedBy = label.appliedBy != null ? Account.id(label.appliedBy) : null;
           rec.labels.add(srl);
         }
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 855bfe9..29bff0a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
+import com.google.gerrit.server.query.change.LegacyChangeIdStrPredicate;
 
 public interface ChangeIndex extends Index<Change.Id, ChangeData> {
   public interface Factory
@@ -27,6 +28,8 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return new LegacyChangeIdPredicate(id);
+    return getSchema().useLegacyNumericFields()
+        ? new LegacyChangeIdPredicate(id)
+        : new LegacyChangeIdStrPredicate(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
index a353a2a..b8e2f3e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index 7945429..7de9e74 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 9e81e75..63c5297 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -19,6 +19,8 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
@@ -31,8 +33,7 @@
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.index.query.TooManyTermsInQueryException;
 import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
@@ -155,7 +156,7 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
-    if (in == out || out instanceof IndexPredicate) {
+    if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
       return in;
@@ -183,7 +184,7 @@
       throws QueryParseException {
     if (isIndexPredicate(in, index)) {
       if (++leafTerms.value > config.maxTerms()) {
-        throw new QueryParseException("too many terms in query");
+        throw new TooManyTermsInQueryException();
       }
       return in;
     } else if (in instanceof LimitPredicate) {
@@ -207,7 +208,7 @@
     for (int i = 0; i < n; i++) {
       Predicate<ChangeData> c = in.getChild(i);
       Predicate<ChangeData> nc = rewriteImpl(c, index, opts, leafTerms);
-      if (nc == c) {
+      if (isSameInstance(nc, c)) {
         isIndexed.set(i);
         newChildren.add(c);
       } else if (nc == null /* cannot rewrite c */) {
@@ -291,4 +292,9 @@
     return p.getChildCount() > 0
         && (p instanceof AndPredicate || p instanceof OrPredicate || p instanceof NotPredicate);
   }
+
+  @SuppressWarnings("ReferenceEquality")
+  private static <T> boolean isSameInstance(T a, T b) {
+    return a == b;
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 4597cbd..f6d86bf 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -22,15 +22,18 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -66,6 +69,7 @@
   @Nullable private final ChangeIndexCollection indexes;
   @Nullable private final ChangeIndex index;
   private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory notesFactory;
   private final ThreadLocalRequestContext context;
   private final ListeningExecutorService batchExecutor;
   private final ListeningExecutorService executor;
@@ -82,6 +86,7 @@
   ChangeIndexer(
       @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
       ThreadLocalRequestContext context,
       PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
@@ -90,6 +95,7 @@
       @Assisted ChangeIndex index) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
@@ -103,6 +109,7 @@
   ChangeIndexer(
       @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
       ThreadLocalRequestContext context,
       PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
@@ -111,6 +118,7 @@
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
@@ -133,6 +141,7 @@
   public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
     IndexTask task = new IndexTask(project, id);
     if (queuedIndexTasks.add(task)) {
+      fireChangeScheduledForIndexingEvent(project.get(), id.get());
       return submit(task);
     }
     return Futures.immediateFuture(null);
@@ -158,6 +167,11 @@
    * @param cd change to index.
    */
   public void index(ChangeData cd) {
+    fireChangeScheduledForIndexingEvent(cd.project().get(), cd.getId().get());
+    doIndex(cd);
+  }
+
+  private void doIndex(ChangeData cd) {
     indexImpl(cd);
 
     // Always double-check whether the change might be stale immediately after
@@ -186,18 +200,30 @@
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       try (TraceTimer traceTimer =
           TraceContext.newTimer(
-              "Replacing change %d in index version %d",
-              cd.getId().get(), i.getSchema().getVersion())) {
+              "Replacing change in index",
+              Metadata.builder()
+                  .changeId(cd.getId().get())
+                  .patchSetId(cd.currentPatchSet().number())
+                  .indexVersion(i.getSchema().getVersion())
+                  .build())) {
         i.replace(cd);
       }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
 
+  private void fireChangeScheduledForIndexingEvent(String projectName, int id) {
+    indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+  }
+
   private void fireChangeIndexedEvent(String projectName, int id) {
     indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
   }
 
+  private void fireChangeScheduledForDeletionFromIndexEvent(int id) {
+    indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+  }
+
   private void fireChangeDeletedFromIndexEvent(int id) {
     indexedListeners.runEach(l -> l.onChangeDeleted(id));
   }
@@ -228,6 +254,7 @@
    * @return future for the deleting task.
    */
   public ListenableFuture<?> deleteAsync(Change.Id id) {
+    fireChangeScheduledForDeletionFromIndexEvent(id.get());
     return submit(new DeleteTask(id));
   }
 
@@ -237,6 +264,11 @@
    * @param id change ID to delete.
    */
   public void delete(Change.Id id) {
+    fireChangeScheduledForDeletionFromIndexEvent(id.get());
+    doDelete(id);
+  }
+
+  private void doDelete(Change.Id id) {
     new DeleteTask(id).call();
   }
 
@@ -327,8 +359,12 @@
     @Override
     public Void callImpl() throws Exception {
       remove();
-      ChangeData cd = changeDataFactory.create(project, id);
-      index(cd);
+      try {
+        ChangeNotes changeNotes = notesFactory.createChecked(project, id);
+        doIndex(changeDataFactory.create(changeNotes));
+      } catch (NoSuchChangeException e) {
+        doDelete(id);
+      }
       return null;
     }
 
@@ -374,7 +410,11 @@
       for (ChangeIndex i : getWriteIndexes()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleting change %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Deleting change in index",
+                Metadata.builder()
+                    .changeId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.delete(id);
         }
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index cde6a64..72153c4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -22,7 +22,7 @@
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V39 =
+  static final Schema<ChangeData> V55 =
       schema(
           ChangeField.ADDED,
           ChangeField.APPROVAL,
@@ -36,11 +36,16 @@
           ChangeField.COMMIT_MESSAGE,
           ChangeField.DELETED,
           ChangeField.DELTA,
+          ChangeField.DIRECTORY,
           ChangeField.DRAFTBY,
           ChangeField.EDITBY,
+          ChangeField.EXACT_AUTHOR,
           ChangeField.EXACT_COMMIT,
+          ChangeField.EXACT_COMMITTER,
           ChangeField.EXACT_TOPIC,
+          ChangeField.EXTENSION,
           ChangeField.FILE_PART,
+          ChangeField.FOOTER,
           ChangeField.FUZZY_TOPIC,
           ChangeField.GROUP,
           ChangeField.HASHTAG,
@@ -49,70 +54,49 @@
           ChangeField.LABEL,
           ChangeField.LEGACY_ID,
           ChangeField.MERGEABLE,
+          ChangeField.ONLY_EXTENSIONS,
           ChangeField.OWNER,
           ChangeField.PATCH_SET,
           ChangeField.PATH,
+          ChangeField.PENDING_REVIEWER,
+          ChangeField.PENDING_REVIEWER_BY_EMAIL,
+          ChangeField.PRIVATE,
           ChangeField.PROJECT,
           ChangeField.PROJECTS,
           ChangeField.REF,
           ChangeField.REF_STATE,
           ChangeField.REF_STATE_PATTERN,
+          ChangeField.REVERT_OF,
           ChangeField.REVIEWEDBY,
           ChangeField.REVIEWER,
+          ChangeField.REVIEWER_BY_EMAIL,
           ChangeField.STAR,
           ChangeField.STARBY,
+          ChangeField.STARTED,
           ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
           ChangeField.STORED_SUBMIT_RECORD_STRICT,
           ChangeField.SUBMISSIONID,
           ChangeField.SUBMIT_RECORD,
+          ChangeField.TOTAL_COMMENT_COUNT,
           ChangeField.TR,
           ChangeField.UNRESOLVED_COMMENT_COUNT,
-          ChangeField.UPDATED);
-
-  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
-  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
-  @Deprecated static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
-
-  @Deprecated
-  static final Schema<ChangeData> V43 =
-      schema(V42, ChangeField.EXACT_AUTHOR, ChangeField.EXACT_COMMITTER);
-
-  @Deprecated
-  static final Schema<ChangeData> V44 =
-      schema(
-          V43,
-          ChangeField.STARTED,
-          ChangeField.PENDING_REVIEWER,
-          ChangeField.PENDING_REVIEWER_BY_EMAIL);
-
-  @Deprecated static final Schema<ChangeData> V45 = schema(V44, ChangeField.REVERT_OF);
-
-  @Deprecated static final Schema<ChangeData> V46 = schema(V45);
-
-  // Removal of draft change workflow requires reindexing
-  @Deprecated static final Schema<ChangeData> V47 = schema(V46);
-
-  // Rename of star label 'mute' to 'reviewed' requires reindexing
-  @Deprecated static final Schema<ChangeData> V48 = schema(V47);
-
-  @Deprecated static final Schema<ChangeData> V49 = schema(V48);
-
-  // Bump Lucene version requires reindexing
-  @Deprecated static final Schema<ChangeData> V50 = schema(V49);
-
-  @Deprecated static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
-
-  @Deprecated static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
-
-  @Deprecated static final Schema<ChangeData> V53 = schema(V52, ChangeField.ONLY_EXTENSIONS);
-
-  @Deprecated static final Schema<ChangeData> V54 = schema(V53, ChangeField.FOOTER);
-
-  @Deprecated static final Schema<ChangeData> V55 = schema(V54, ChangeField.DIRECTORY);
+          ChangeField.UPDATED,
+          ChangeField.WIP);
 
   // The computation of the 'extension' field is changed, hence reindexing is required.
-  static final Schema<ChangeData> V56 = schema(V55);
+  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
+
+  // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
+  // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
+  // document id type is replaced with string document id type.
+  static final Schema<ChangeData> V57 =
+      new Schema.Builder<ChangeData>()
+          .add(V56)
+          .remove(ChangeField.LEGACY_ID)
+          .add(ChangeField.LEGACY_ID_STR)
+          .legacyNumericFields(false)
+          .build();
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
index 9be93f7..ae3b729 100644
--- a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index ed09eed..57a2091 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
@@ -31,7 +32,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import java.util.HashMap;
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index fd12345..f6d3b6f 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -17,37 +17,30 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
-import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Config;
 
@@ -58,15 +51,12 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeIndexCollection indexes;
-  private final ChangeNotes.Factory notesFactory;
   private final AllUsersName allUsersName;
   private final AccountCache accountCache;
   private final Provider<AccountIndexer> indexer;
   private final ListeningExecutorService executor;
   private final boolean enabled;
 
-  private final Set<Index> queuedIndexTasks = Collections.newSetFromMap(new ConcurrentHashMap<>());
-
   @Inject
   ReindexAfterRefUpdate(
       @GerritServerConfig Config cfg,
@@ -74,7 +64,6 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeIndexer.Factory indexerFactory,
       ChangeIndexCollection indexes,
-      ChangeNotes.Factory notesFactory,
       AllUsersName allUsersName,
       AccountCache accountCache,
       Provider<AccountIndexer> indexer,
@@ -83,7 +72,6 @@
     this.queryProvider = queryProvider;
     this.indexerFactory = indexerFactory;
     this.indexes = indexes;
-    this.notesFactory = notesFactory;
     this.allUsersName = allUsersName;
     this.accountCache = accountCache;
     this.indexer = indexer;
@@ -113,12 +101,9 @@
           @Override
           public void onSuccess(List<Change> changes) {
             for (Change c : changes) {
-              Index task = new Index(event, c.getId());
-              if (queuedIndexTasks.add(task)) {
-                // Don't retry indefinitely; if this fails changes may be stale.
-                @SuppressWarnings("unused")
-                Future<?> possiblyIgnoredError = executor.submit(task);
-              }
+              @SuppressWarnings("unused")
+              Future<?> possiblyIgnoredError =
+                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
             }
           }
 
@@ -160,11 +145,11 @@
     @Override
     protected List<Change> impl(RequestContext ctx) {
       String ref = event.getRefName();
-      Project.NameKey project = new Project.NameKey(event.getProjectName());
+      Project.NameKey project = Project.nameKey(event.getProjectName());
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
-      return asChanges(queryProvider.get().byBranchNew(new Branch.NameKey(project, ref)));
+      return asChanges(queryProvider.get().byBranchNew(BranchNameKey.create(project, ref)));
     }
 
     @Override
@@ -178,51 +163,4 @@
     @Override
     protected void remove() {}
   }
-
-  private class Index extends Task<Void> {
-    private final Change.Id id;
-
-    Index(Event event, Change.Id id) {
-      super(event);
-      this.id = id;
-    }
-
-    @Override
-    protected Void impl(RequestContext ctx) throws IOException {
-      // Reload change, as some time may have passed since GetChanges.
-      remove();
-      try {
-        Change c =
-            notesFactory.createChecked(new Project.NameKey(event.getProjectName()), id).getChange();
-        indexerFactory.create(executor, indexes).index(c);
-      } catch (NoSuchChangeException e) {
-        indexerFactory.create(executor, indexes).delete(id);
-      }
-      return null;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(Index.class, id.get());
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof Index)) {
-        return false;
-      }
-      Index other = (Index) obj;
-      return id.get() == other.id.get();
-    }
-
-    @Override
-    public String toString() {
-      return "Index change " + id.get() + " of project " + event.getProjectName();
-    }
-
-    @Override
-    protected void remove() {
-      queuedIndexTasks.remove(this);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 338cf3d..47fd7ba 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -29,11 +29,11 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.RefState;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -131,8 +131,7 @@
       String s = new String(b, UTF_8);
       List<String> parts = Splitter.on(':').splitToList(s);
       RefStatePattern.check(parts.size() == 2, s);
-      result.put(
-          new Project.NameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
+      result.put(Project.nameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
     }
     return result;
   }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 3474934..0dbbbc5 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -23,8 +23,8 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.SiteIndexer;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index 29e3867..a3d913d 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -23,13 +23,13 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for groups. */
@@ -82,7 +82,7 @@
       storedOnly("ref_state")
           .build(
               g -> {
-                byte[] a = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+                byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 6a430f8..daa2131 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.index.group;
 
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.query.group.GroupPredicates;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 531c446..9d74b7d 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
index d117dfd..e403752 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
index 5d9232e..25d5840 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexer.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.index.group;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 
 public interface GroupIndexer {
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index 5982de7..790066d 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -17,12 +17,13 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -90,13 +91,21 @@
       if (internalGroup.isPresent()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Replacing group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+                "Replacing group",
+                Metadata.builder()
+                    .groupUuid(uuid.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.replace(internalGroup.get());
         }
       } else {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleting group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+                "Deleting group",
+                Metadata.builder()
+                    .groupUuid(uuid.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.delete(uuid);
         }
       }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 6d0f3b6..e64e2eb 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -43,7 +43,11 @@
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
 
   // Lucene index was changed to add an additional field for sorting.
-  static final Schema<InternalGroup> V7 = schema(V6);
+  @Deprecated static final Schema<InternalGroup> V7 = schema(V6);
+
+  // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
+  // to offer fast single- and multi-dimensional numeric range.
+  static final Schema<InternalGroup> V8 = schema(V7, false);
 
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
 
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 32393b06..dfdf3ca 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index.group;
 
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
@@ -21,7 +22,6 @@
 import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.util.HashSet;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 7900287..3a721c3 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 305cd25..b760fd7 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -21,10 +21,10 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java b/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
index ce2b634..6a844b5 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.index.project;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 
 public class ProjectIndexDefinition
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
index 6d6f78d..4de83be 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -17,12 +17,13 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexer;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -78,8 +79,11 @@
       for (ProjectIndex i : getWriteIndexes()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Replacing project %s in index version %d",
-                nameKey.get(), i.getSchema().getVersion())) {
+                "Replacing project",
+                Metadata.builder()
+                    .projectName(nameKey.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.replace(projectData);
         }
       }
@@ -89,8 +93,11 @@
       for (ProjectIndex i : getWriteIndexes()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleting project %s in index version %d",
-                nameKey.get(), i.getSchema().getVersion())) {
+                "Deleting project",
+                Metadata.builder()
+                    .projectName(nameKey.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
           i.delete(nameKey);
         }
       }
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index dc5ebc6..e4c1a7d 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -17,6 +17,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.RefState;
@@ -25,8 +27,6 @@
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index ed58d5b..fd0c4f1 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -5,10 +5,10 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//lib:automaton",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
+        "//lib:jgit-archive",
     ],
 )
diff --git a/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index c7f2ecd..296cf22 100644
--- a/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -31,7 +31,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.reviewdb.client.CodedEnum;
+import com.google.gerrit.entities.CodedEnum;
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index 672cb75..7af34f7 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -8,12 +8,15 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/util/time",
         "//lib:gson",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
+        "//lib/guice",
         "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/logging/CallerFinder.java b/java/com/google/gerrit/server/logging/CallerFinder.java
index c27dbbb..bd7e608 100644
--- a/java/com/google/gerrit/server/logging/CallerFinder.java
+++ b/java/com/google/gerrit/server/logging/CallerFinder.java
@@ -142,13 +142,27 @@
 
   /**
    * The minimum number of calls known to have occurred between the first call to the target class
-   * and the call of {@link #findCaller()}. If in doubt, specify zero here to avoid accidentally
+   * and the call of {@link #findCallerLazy()}. If in doubt, specify zero here to avoid accidentally
    * skipping past the caller.
    *
    * @return the number of stack elements to skip when computing the caller
    */
   public abstract int skip();
 
+  /**
+   * Packages that should be ignored and not be considered as caller once a target has been found.
+   *
+   * @return the ignored packages
+   */
+  public abstract ImmutableList<String> ignoredPackages();
+
+  /**
+   * Classes that should be ignored and not be considered as caller once a target has been found.
+   *
+   * @return the qualified names of the ignored classes
+   */
+  public abstract ImmutableList<String> ignoredClasses();
+
   @AutoValue.Builder
   public abstract static class Builder {
     abstract ImmutableList.Builder<Class<?>> targetsBuilder();
@@ -164,10 +178,24 @@
 
     public abstract Builder skip(int skip);
 
+    abstract ImmutableList.Builder<String> ignoredPackagesBuilder();
+
+    public Builder addIgnoredPackage(String ignoredPackage) {
+      ignoredPackagesBuilder().add(ignoredPackage);
+      return this;
+    }
+
+    abstract ImmutableList.Builder<String> ignoredClassesBuilder();
+
+    public Builder addIgnoredClass(Class<?> ignoredClass) {
+      ignoredClassesBuilder().add(ignoredClass.getName());
+      return this;
+    }
+
     public abstract CallerFinder build();
   }
 
-  public LazyArg<String> findCaller() {
+  public LazyArg<String> findCallerLazy() {
     return lazy(
         () ->
             targets().stream()
@@ -194,7 +222,9 @@
         StackTraceElement element = stack[index];
         if (isCaller(target, element.getClassName(), matchSubClasses())) {
           foundCaller = true;
-        } else if (foundCaller) {
+        } else if (foundCaller
+            && !ignoredPackages().contains(getPackageName(element))
+            && !ignoredClasses().contains(element.getClassName())) {
           return Optional.of(element.toString());
         }
       }
@@ -206,6 +236,11 @@
     }
   }
 
+  private static String getPackageName(StackTraceElement element) {
+    String className = element.getClassName();
+    return className.substring(0, className.lastIndexOf("."));
+  }
+
   private boolean isCaller(Class<?> target, String className, boolean matchSubClasses)
       throws ClassNotFoundException {
     if (matchSubClasses) {
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index cb7d01e..36c7e9e 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -14,8 +14,14 @@
 
 package com.google.gerrit.server.logging;
 
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.flogger.backend.Tags;
+import com.google.inject.Provider;
+import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.logging.Level;
 
@@ -35,6 +41,9 @@
 
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
+      new ThreadLocal<>();
 
   private LoggingContext() {}
 
@@ -47,14 +56,42 @@
     if (runnable instanceof LoggingContextAwareRunnable) {
       return runnable;
     }
-    return new LoggingContextAwareRunnable(runnable);
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareRunnable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareRunnable(
+        runnable, getInstance().getMutablePerformanceLogRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
     if (callable instanceof LoggingContextAwareCallable) {
       return callable;
     }
-    return new LoggingContextAwareCallable<>(callable);
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareCallable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareCallable<>(
+        callable, getInstance().getMutablePerformanceLogRecords());
+  }
+
+  public boolean isEmpty() {
+    return tags.get() == null
+        && forceLogging.get() == null
+        && performanceLogging.get() == null
+        && performanceLogRecords.get() == null;
+  }
+
+  public void clear() {
+    tags.remove();
+    forceLogging.remove();
+    performanceLogging.remove();
+    performanceLogRecords.remove();
   }
 
   @Override
@@ -119,4 +156,113 @@
     }
     return Boolean.TRUE.equals(oldValue);
   }
+
+  boolean isPerformanceLogging() {
+    Boolean isPerformanceLogging = performanceLogging.get();
+    return isPerformanceLogging != null ? isPerformanceLogging : false;
+  }
+
+  /**
+   * Enables performance logging.
+   *
+   * <p>It's important to enable performance logging only in a context that ensures to consume the
+   * captured performance log records. Otherwise captured performance log records might leak into
+   * other requests that are executed by the same thread (if a thread pool is used to process
+   * requests).
+   *
+   * @param enable whether performance logging should be enabled.
+   * @return whether performance logging was be enabled before invoking this method (old value).
+   */
+  boolean performanceLogging(boolean enable) {
+    Boolean oldValue = performanceLogging.get();
+    if (enable) {
+      performanceLogging.set(true);
+    } else {
+      performanceLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds a performance log record, if performance logging is enabled.
+   *
+   * @param recordProvider Provider for the performance log record. This provider is only invoked if
+   *     performance logging is enabled. This means if performance logging is disabled, we avoid the
+   *     creation of a {@link PerformanceLogRecord}.
+   */
+  public void addPerformanceLogRecord(Provider<PerformanceLogRecord> recordProvider) {
+    if (!isPerformanceLogging()) {
+      // return early and avoid the creation of a PerformanceLogRecord
+      return;
+    }
+
+    getMutablePerformanceLogRecords().add(recordProvider.get());
+  }
+
+  ImmutableList<PerformanceLogRecord> getPerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearPerformanceLogEntries() {
+    performanceLogRecords.remove();
+  }
+
+  /**
+   * Set the performance log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newPerformanceLogRecords performance log records that should be set
+   */
+  void setPerformanceLogRecords(List<PerformanceLogRecord> newPerformanceLogRecords) {
+    if (newPerformanceLogRecords.isEmpty()) {
+      performanceLogRecords.remove();
+      return;
+    }
+
+    getMutablePerformanceLogRecords().set(newPerformanceLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutablePerformanceLogRecords} instance for storing performance log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutablePerformanceLogRecords} instance is
+   * directly stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the performance log records that are added in the new thread are added to the
+   * same {@link MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and
+   * {@link LoggingContextAwareCallable}). This is important since performance log records are
+   * processed only at the end of the request and performance log records that are created in
+   * another thread should not get lost.
+   *
+   * @param mutablePerformanceLogRecords the {@link MutablePerformanceLogRecords} instance in which
+   *     performance log records should be stored
+   */
+  void setMutablePerformanceLogRecords(MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+    performanceLogRecords.set(requireNonNull(mutablePerformanceLogRecords));
+  }
+
+  private MutablePerformanceLogRecords getMutablePerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records == null) {
+      records = new MutablePerformanceLogRecords();
+      performanceLogRecords.set(records);
+    }
+    return records;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("tags", tags.get())
+        .add("forceLogging", forceLogging.get())
+        .add("performanceLogging", performanceLogging.get())
+        .add("performanceLogRecords", performanceLogRecords.get())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 6aff5c4..d2701d7 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
 import java.util.concurrent.Callable;
 
 /**
@@ -31,16 +32,30 @@
  * @see LoggingContextAwareRunnable
  */
 class LoggingContextAwareCallable<T> implements Callable<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Callable<T> callable;
   private final Thread callingThread;
   private final ImmutableSetMultimap<String, String> tags;
   private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
 
-  LoggingContextAwareCallable(Callable<T> callable) {
+  /**
+   * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
+   *
+   * @param callable Callable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareCallable(
+      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
   }
 
   @Override
@@ -50,17 +65,29 @@
       return callable.call();
     }
 
-    // propagate logging context
     LoggingContext loggingCtx = LoggingContext.getInstance();
-    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
-    boolean oldForceLogging = loggingCtx.isLoggingForced();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       return callable.call();
     } finally {
-      loggingCtx.setTags(oldTags);
-      loggingCtx.forceLogging(oldForceLogging);
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index 0bd7d00..23162b1 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
 
 /**
  * Wrapper for a {@link Runnable} that copies the {@link LoggingContext} from the current thread to
@@ -49,16 +50,30 @@
  * @see LoggingContextAwareCallable
  */
 public class LoggingContextAwareRunnable implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Runnable runnable;
   private final Thread callingThread;
   private final ImmutableSetMultimap<String, String> tags;
   private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
 
-  LoggingContextAwareRunnable(Runnable runnable) {
+  /**
+   * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
+   *
+   * @param runnable Runnable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareRunnable(
+      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
   }
 
   public Runnable unwrap() {
@@ -73,17 +88,29 @@
       return;
     }
 
-    // propagate logging context
     LoggingContext loggingCtx = LoggingContext.getInstance();
-    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
-    boolean oldForceLogging = loggingCtx.isLoggingForced();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       runnable.run();
     } finally {
-      loggingCtx.setTags(oldTags);
-      loggingCtx.forceLogging(oldForceLogging);
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
new file mode 100644
index 0000000..7af204e
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -0,0 +1,339 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.LazyArg;
+import com.google.common.flogger.LazyArgs;
+import com.google.gerrit.common.Nullable;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Optional;
+
+/** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
+@AutoValue
+public abstract class Metadata {
+  // The numeric ID of an account.
+  public abstract Optional<Integer> accountId();
+
+  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+  // PLUGIN_UPDATE).
+  public abstract Optional<String> actionType();
+
+  // An authentication domain name.
+  public abstract Optional<String> authDomainName();
+
+  // The name of a branch.
+  public abstract Optional<String> branchName();
+
+  // Key of an entity in a cache.
+  public abstract Optional<String> cacheKey();
+
+  // The name of a cache.
+  public abstract Optional<String> cacheName();
+
+  // The name of the implementation class.
+  public abstract Optional<String> className();
+
+  // The numeric ID of a change.
+  public abstract Optional<Integer> changeId();
+
+  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  public abstract Optional<String> changeIdType();
+
+  // The type of an event.
+  public abstract Optional<String> eventType();
+
+  // The value of the @Export annotation which was used to register a plugin extension.
+  public abstract Optional<String> exportValue();
+
+  // Path of a file in a repository.
+  public abstract Optional<String> filePath();
+
+  // Garbage collector name.
+  public abstract Optional<String> garbageCollectorName();
+
+  // Git operation (CLONE, FETCH).
+  public abstract Optional<String> gitOperation();
+
+  // The numeric ID of an internal group.
+  public abstract Optional<Integer> groupId();
+
+  // The name of a group.
+  public abstract Optional<String> groupName();
+
+  // The UUID of a group.
+  public abstract Optional<String> groupUuid();
+
+  // HTTP status response code.
+  public abstract Optional<Integer> httpStatus();
+
+  // The name of a secondary index.
+  public abstract Optional<String> indexName();
+
+  // The version of a secondary index.
+  public abstract Optional<Integer> indexVersion();
+
+  // The name of the implementation method.
+  public abstract Optional<String> methodName();
+
+  // One or more resources
+  public abstract Optional<Boolean> multiple();
+
+  // The name of an operation that is performed.
+  public abstract Optional<String> operationName();
+
+  // Partial or full computation
+  public abstract Optional<Boolean> partial();
+
+  // Path of a metadata file in NoteDb.
+  public abstract Optional<String> noteDbFilePath();
+
+  // Name of a metadata ref in NoteDb.
+  public abstract Optional<String> noteDbRefName();
+
+  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  public abstract Optional<String> noteDbSequenceType();
+
+  // Name of a "table" in NoteDb (if set, always CHANGES).
+  public abstract Optional<String> noteDbTable();
+
+  // The ID of a patch set.
+  public abstract Optional<Integer> patchSetId();
+
+  // Plugin metadata that doesn't fit into any other category.
+  public abstract ImmutableList<PluginMetadata> pluginMetadata();
+
+  // The name of a plugin.
+  public abstract Optional<String> pluginName();
+
+  // The name of a Gerrit project (aka Git repository).
+  public abstract Optional<String> projectName();
+
+  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  public abstract Optional<String> pushType();
+
+  // The number of resources that is processed.
+  public abstract Optional<Integer> resourceCount();
+
+  // The name of a REST view.
+  public abstract Optional<String> restViewName();
+
+  // The SHA1 of Git commit.
+  public abstract Optional<String> revision();
+
+  // The username of an account.
+  public abstract Optional<String> username();
+
+  /**
+   * Returns a string representation of this instance that is suitable for logging. This is wrapped
+   * in a {@link LazyArg} because it is expensive to evaluate.
+   *
+   * <p>{@link #toString()} formats the {@link Optional} fields as {@code key=Optional[value]} or
+   * {@code key=Optional.empty}. Since this class has many optional fields from which usually only a
+   * few are populated this leads to long string representations such as
+   *
+   * <pre>
+   * Metadata{accountId=Optional.empty, actionType=Optional.empty, authDomainName=Optional.empty,
+   * branchName=Optional.empty, cacheKey=Optional.empty, cacheName=Optional.empty,
+   * className=Optional.empty, changeId=Optional[9212550], changeIdType=Optional.empty,
+   * eventType=Optional.empty, exportValue=Optional.empty, filePath=Optional.empty,
+   * garbageCollectorName=Optional.empty, gitOperation=Optional.empty, groupId=Optional.empty,
+   * groupName=Optional.empty, groupUuid=Optional.empty, httpStatus=Optional.empty,
+   * indexName=Optional.empty, indexVersion=Optional[0], methodName=Optional.empty,
+   * multiple=Optional.empty, operationName=Optional.empty, partial=Optional.empty,
+   * noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
+   * noteDbSequenceType=Optional.empty, noteDbTable=Optional.empty, patchSetId=Optional.empty,
+   * pluginMetadata=[], pluginName=Optional.empty, projectName=Optional.empty,
+   * pushType=Optional.empty, resourceCount=Optional.empty, restViewName=Optional.empty,
+   * revision=Optional.empty, username=Optional.empty}
+   * </pre>
+   *
+   * <p>That's hard to read in logs. This is why this method
+   *
+   * <ul>
+   *   <li>drops fields which have {@code Optional.empty} as value and
+   *   <li>reformats values that are {@code Optional[value]} to {@code value}.
+   * </ul>
+   *
+   * <p>For the example given above the formatted string would look like this:
+   *
+   * <pre>
+   * Metadata{changeId=9212550, indexVersion=0, pluginMetadata=[]}
+   * </pre>
+   *
+   * @return string representation of this instance that is suitable for logging
+   */
+  LazyArg<String> toStringForLoggingLazy() {
+    // Don't use a lambda because different compilers generate different method names for lambdas,
+    // e.g. "lambda$myFunction$0" vs. just "lambda$0" in Eclipse. We need to identify the method
+    // by name to skip it and avoid infinite recursion.
+    return LazyArgs.lazy(this::toStringForLoggingImpl);
+  }
+
+  private String toStringForLoggingImpl() {
+    // Append class name.
+    String className = getClass().getSimpleName();
+    if (className.startsWith("AutoValue_")) {
+      className = className.substring(10);
+    }
+    ToStringHelper stringHelper = MoreObjects.toStringHelper(className);
+
+    // Append key-value pairs for field which are set.
+    Method[] methods = Metadata.class.getDeclaredMethods();
+    Arrays.sort(methods, Comparator.comparing(Method::getName));
+    for (Method method : methods) {
+      if (Modifier.isStatic(method.getModifiers())) {
+        // skip static method
+        continue;
+      }
+
+      if (method.getName().equals("toStringForLoggingLazy")
+          || method.getName().equals("toStringForLoggingImpl")) {
+        // Don't call myself in infinite recursion.
+        continue;
+      }
+
+      if (method.getReturnType().equals(Void.TYPE) || method.getParameterCount() > 0) {
+        // skip method since it's not a getter
+        continue;
+      }
+
+      method.setAccessible(true);
+
+      Object returnValue;
+      try {
+        returnValue = method.invoke(this);
+      } catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
+        // should never happen
+        throw new IllegalStateException(e);
+      }
+
+      if (returnValue instanceof Optional) {
+        Optional<?> fieldValueOptional = (Optional<?>) returnValue;
+        if (!fieldValueOptional.isPresent()) {
+          // drop this key-value pair
+          continue;
+        }
+
+        // format as 'key=value' instead of 'key=Optional[value]'
+        stringHelper.add(method.getName(), fieldValueOptional.get());
+      } else {
+        // not an Optional value, keep as is
+        stringHelper.add(method.getName(), returnValue);
+      }
+    }
+
+    return stringHelper.toString();
+  }
+
+  public static Metadata.Builder builder() {
+    return new AutoValue_Metadata.Builder();
+  }
+
+  public static Metadata empty() {
+    return builder().build();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder accountId(int accountId);
+
+    public abstract Builder actionType(@Nullable String actionType);
+
+    public abstract Builder authDomainName(@Nullable String authDomainName);
+
+    public abstract Builder branchName(@Nullable String branchName);
+
+    public abstract Builder cacheKey(@Nullable String cacheKey);
+
+    public abstract Builder cacheName(@Nullable String cacheName);
+
+    public abstract Builder className(@Nullable String className);
+
+    public abstract Builder changeId(int changeId);
+
+    public abstract Builder changeIdType(@Nullable String changeIdType);
+
+    public abstract Builder eventType(@Nullable String eventType);
+
+    public abstract Builder exportValue(@Nullable String exportValue);
+
+    public abstract Builder filePath(@Nullable String filePath);
+
+    public abstract Builder garbageCollectorName(@Nullable String garbageCollectorName);
+
+    public abstract Builder gitOperation(@Nullable String gitOperation);
+
+    public abstract Builder groupId(int groupId);
+
+    public abstract Builder groupName(@Nullable String groupName);
+
+    public abstract Builder groupUuid(@Nullable String groupUuid);
+
+    public abstract Builder httpStatus(int httpStatus);
+
+    public abstract Builder indexName(@Nullable String indexName);
+
+    public abstract Builder indexVersion(int indexVersion);
+
+    public abstract Builder methodName(@Nullable String methodName);
+
+    public abstract Builder multiple(boolean multiple);
+
+    public abstract Builder operationName(String operationName);
+
+    public abstract Builder partial(boolean partial);
+
+    public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
+
+    public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
+
+    public abstract Builder noteDbSequenceType(@Nullable String noteDbSequenceType);
+
+    public abstract Builder noteDbTable(@Nullable String noteDbTable);
+
+    public abstract Builder patchSetId(int patchSetId);
+
+    abstract ImmutableList.Builder<PluginMetadata> pluginMetadataBuilder();
+
+    public Builder addPluginMetadata(PluginMetadata pluginMetadata) {
+      pluginMetadataBuilder().add(pluginMetadata);
+      return this;
+    }
+
+    public abstract Builder pluginName(@Nullable String pluginName);
+
+    public abstract Builder projectName(@Nullable String projectName);
+
+    public abstract Builder pushType(@Nullable String pushType);
+
+    public abstract Builder resourceCount(int resourceCount);
+
+    public abstract Builder restViewName(@Nullable String restViewName);
+
+    public abstract Builder revision(@Nullable String revision);
+
+    public abstract Builder username(@Nullable String username);
+
+    public abstract Metadata build();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
new file mode 100644
index 0000000..4ee70d7
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for performance log records.
+ *
+ * <p>This class is intended to keep track of performance log records in {@link LoggingContext}. It
+ * needs to be thread-safe because it gets shared between threads when the logging context is copied
+ * to another thread (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}. In this case the logging contexts of both threads share the same
+ * instance of this class. This is important since performance log records are processed only at the
+ * end of a request and performance log records that are created in another thread should not get
+ * lost.
+ */
+public class MutablePerformanceLogRecords {
+  private final ArrayList<PerformanceLogRecord> performanceLogRecords = new ArrayList<>();
+
+  public synchronized void add(PerformanceLogRecord record) {
+    performanceLogRecords.add(record);
+  }
+
+  public synchronized void set(List<PerformanceLogRecord> records) {
+    performanceLogRecords.clear();
+    performanceLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<PerformanceLogRecord> list() {
+    return ImmutableList.copyOf(performanceLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("performanceLogRecords", performanceLogRecords)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
index f70a8db..83009a6 100644
--- a/java/com/google/gerrit/server/logging/MutableTags.java
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -110,4 +111,10 @@
     tagMap.forEach(tagsBuilder::addTag);
     tags = tagsBuilder.build();
   }
+
+  @Override
+  public String toString() {
+    buildTags();
+    return MoreObjects.toStringHelper(this).add("tags", tags).toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
new file mode 100644
index 0000000..b6dafdc
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Context for capturing performance log records. When the context is closed the performance log
+ * records are handed over to the registered {@link PerformanceLogger}s.
+ *
+ * <p>Capturing performance log records is disabled if there are no {@link PerformanceLogger}
+ * registered (in this case the captured performance log records would never be used).
+ *
+ * <p>It's important to enable capturing of performance log records in a context that ensures to
+ * consume the captured performance log records. Otherwise captured performance log records might
+ * leak into other requests that are executed by the same thread (if a thread pool is used to
+ * process requests).
+ */
+public class PerformanceLogContext implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Do not use PluginSetContext. PluginSetContext traces the plugin latency with a timer metric
+  // which would result in a performance log and we don't want to log the performance of writing
+  // a performance log in the performance log (endless loop).
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private final boolean oldPerformanceLogging;
+  private final ImmutableList<PerformanceLogRecord> oldPerformanceLogRecords;
+
+  public PerformanceLogContext(
+      Config gerritConfig, DynamicSet<PerformanceLogger> performanceLoggers) {
+    this.performanceLoggers = performanceLoggers;
+
+    // Just in case remember the old state and reset performance log entries.
+    this.oldPerformanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.oldPerformanceLogRecords = LoggingContext.getInstance().getPerformanceLogRecords();
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+
+    // Do not create performance log entries if performance logging is disabled or if no
+    // PerformanceLogger is registered.
+    boolean enablePerformanceLogging =
+        gerritConfig.getBoolean("tracing", "performanceLogging", true);
+    LoggingContext.getInstance()
+        .performanceLogging(
+            enablePerformanceLogging && !Iterables.isEmpty(performanceLoggers.entries()));
+  }
+
+  @Override
+  public void close() {
+    if (LoggingContext.getInstance().isPerformanceLogging()) {
+      runEach(performanceLoggers, LoggingContext.getInstance().getPerformanceLogRecords());
+    }
+
+    // Restore old state. Required to support nesting of PerformanceLogContext's.
+    LoggingContext.getInstance().performanceLogging(oldPerformanceLogging);
+    LoggingContext.getInstance().setPerformanceLogRecords(oldPerformanceLogRecords);
+  }
+
+  /**
+   * Invokes all performance loggers.
+   *
+   * <p>Similar to how {@code com.google.gerrit.server.plugincontext.PluginContext} invokes plugins
+   * but without recording metrics for invoking {@link PerformanceLogger}s.
+   *
+   * @param performanceLoggers the performance loggers that should be invoked
+   * @param performanceLogRecords the performance log records that should be handed over to the
+   *     performance loggers
+   */
+  private static void runEach(
+      DynamicSet<PerformanceLogger> performanceLoggers,
+      ImmutableList<PerformanceLogRecord> performanceLogRecords) {
+    performanceLoggers
+        .entries()
+        .forEach(
+            p -> {
+              try (TraceContext traceContext = newPluginTrace(p)) {
+                performanceLogRecords.forEach(r -> r.writeTo(p.get()));
+              } catch (Throwable e) {
+                logger.atWarning().withCause(e).log(
+                    "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
+              }
+            });
+  }
+
+  /**
+   * Opens a trace context for a plugin that implements {@link PerformanceLogger}.
+   *
+   * <p>Basically the same as {@code
+   * com.google.gerrit.server.plugincontext.PluginContext#newTrace(Extension<T>)}. We have this
+   * method here to avoid a dependency on PluginContext which lives in
+   * "//java/com/google/gerrit/server". This package ("//java/com/google/gerrit/server/logging")
+   * should have as few dependencies as possible.
+   *
+   * @param extension performance logger extension
+   * @return the trace context
+   */
+  private static TraceContext newPluginTrace(Extension<PerformanceLogger> extension) {
+    return TraceContext.open().addPluginTag(extension.getPluginName());
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
new file mode 100644
index 0000000..046eeb3
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/**
+ * The record of an operation for which the execution time was measured.
+ *
+ * <p>Metadata to provide additional context can be included by providing a {@link Metadata}
+ * instance.
+ */
+@AutoValue
+public abstract class PerformanceLogRecord {
+  /**
+   * Creates a performance log record without meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(String operation, long durationMs) {
+    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.empty());
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param metadata metadata
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
+    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.of(metadata));
+  }
+
+  public abstract String operation();
+
+  public abstract long durationMs();
+
+  public abstract Optional<Metadata> metadata();
+
+  void writeTo(PerformanceLogger performanceLogger) {
+    if (metadata().isPresent()) {
+      performanceLogger.log(operation(), durationMs(), metadata().get());
+    } else {
+      performanceLogger.log(operation(), durationMs());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
new file mode 100644
index 0000000..74a1684
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Extension point for logging performance records.
+ *
+ * <p>This extension point is invoked for all operations for which the execution time is measured.
+ * The invocation of the extension point does not happen immediately, but only at the end of a
+ * request (REST call, SSH call, git push). Implementors can write the execution times into a
+ * performance log for further analysis.
+ *
+ * <p>For optimal performance implementors should overwrite the default <code>log</code> methods to
+ * avoid an unneeded instantiation of Metadata.
+ */
+@ExtensionPoint
+public interface PerformanceLogger {
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   */
+  default void log(String operation, long durationMs) {
+    log(operation, durationMs, Metadata.empty());
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param metadata metadata
+   */
+  void log(String operation, long durationMs, Metadata metadata);
+}
diff --git a/java/com/google/gerrit/server/logging/PluginMetadata.java b/java/com/google/gerrit/server/logging/PluginMetadata.java
new file mode 100644
index 0000000..21f7359
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PluginMetadata.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/**
+ * Key-value pair for custom metadata that is provided by plugins.
+ *
+ * <p>PluginMetadata allows plugins to include custom metadata into the {@link Metadata} instances
+ * that are provided as context for performance tracing.
+ *
+ * <p>Plugins should use PluginMetadata only for metadata kinds that are not known to Gerrit core
+ * (metadata for which {@link Metadata} doesn't have a dedicated field).
+ */
+@AutoValue
+public abstract class PluginMetadata {
+  public static PluginMetadata create(String key, @Nullable String value) {
+    return new AutoValue_PluginMetadata(key, Optional.ofNullable(value));
+  }
+
+  public abstract String key();
+
+  public abstract Optional<String> value();
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 5c0406d..21a4ce6 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;
@@ -155,116 +156,66 @@
   /**
    * Opens a new timer that logs the time for an operation if request tracing is enabled.
    *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param message the message
+   * @param operation the name of operation the is being performed
    * @return the trace timer
    */
-  public static TraceTimer newTimer(String message) {
-    return new TraceTimer(message);
+  public static TraceTimer newTimer(String operation) {
+    return new TraceTimer(requireNonNull(operation, "operation is required"));
   }
 
   /**
    * Opens a new timer that logs the time for an operation if request tracing is enabled.
    *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg argument for the message
+   * @param operation the name of operation the is being performed
+   * @param metadata metadata
    * @return the trace timer
    */
-  public static TraceTimer newTimer(String format, Object arg) {
-    return new TraceTimer(format, arg);
-  }
-
-  /**
-   * Opens a new timer that logs the time for an operation if request tracing is enabled.
-   *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @return the trace timer
-   */
-  public static TraceTimer newTimer(String format, Object arg1, Object arg2) {
-    return new TraceTimer(format, arg1, arg2);
-  }
-
-  /**
-   * Opens a new timer that logs the time for an operation if request tracing is enabled.
-   *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @param arg3 third argument for the message
-   * @return the trace timer
-   */
-  public static TraceTimer newTimer(String format, Object arg1, Object arg2, Object arg3) {
-    return new TraceTimer(format, arg1, arg2, arg3);
-  }
-
-  /**
-   * Opens a new timer that logs the time for an operation if request tracing is enabled.
-   *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @param arg3 third argument for the message
-   * @param arg4 fourth argument for the message
-   * @return the trace timer
-   */
-  public static TraceTimer newTimer(
-      String format, Object arg1, Object arg2, Object arg3, Object arg4) {
-    return new TraceTimer(format, arg1, arg2, arg3, arg4);
+  public static TraceTimer newTimer(String operation, Metadata metadata) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(metadata, "metadata is required"));
   }
 
   public static class TraceTimer implements AutoCloseable {
     private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-    private final Consumer<Long> logFn;
+    private final Consumer<Long> doneLogFn;
     private final Stopwatch stopwatch;
 
-    private TraceTimer(String message) {
-      this(elapsedMs -> logger.atFine().log(message + " (%d ms)", elapsedMs));
-    }
-
-    private TraceTimer(String format, @Nullable Object arg) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg, elapsedMs));
-    }
-
-    private TraceTimer(String format, @Nullable Object arg1, @Nullable Object arg2) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, elapsedMs));
-    }
-
-    private TraceTimer(
-        String format, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, arg3, elapsedMs));
-    }
-
-    private TraceTimer(
-        String format,
-        @Nullable Object arg1,
-        @Nullable Object arg2,
-        @Nullable Object arg3,
-        @Nullable Object arg4) {
+    private TraceTimer(String operation) {
       this(
-          elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, arg3, arg4, elapsedMs));
+          () -> logger.atFine().log("Starting timer for %s", operation),
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
+            logger.atFine().log("%s done (%d ms)", operation, elapsedMs);
+          });
     }
 
-    private TraceTimer(Consumer<Long> logFn) {
-      this.logFn = logFn;
+    private TraceTimer(String operation, Metadata metadata) {
+      this(
+          () ->
+              logger.atFine().log(
+                  "Starting timer for %s (%s)", operation, metadata.toStringForLoggingLazy()),
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () -> PerformanceLogRecord.create(operation, elapsedMs, metadata));
+            logger.atFine().log(
+                "%s (%s) done (%d ms)", operation, metadata.toStringForLoggingLazy(), elapsedMs);
+          });
+    }
+
+    private TraceTimer(Runnable startLogFn, Consumer<Long> doneLogFn) {
+      startLogFn.run();
+      this.doneLogFn = doneLogFn;
       this.stopwatch = Stopwatch.createStarted();
     }
 
     @Override
     public void close() {
       stopwatch.stop();
-      logFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+      doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
     }
   }
 
@@ -286,6 +237,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);
   }
@@ -299,6 +256,15 @@
     return this;
   }
 
+  public boolean isTracing() {
+    return LoggingContext.getInstance().isLoggingForced();
+  }
+
+  public Optional<String> getTraceId() {
+    return LoggingContext.getInstance().getTagsAsMap().get(RequestId.Type.TRACE_ID.name()).stream()
+        .findFirst();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index 5b5f33d..2ff5fc3 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index 4afcc7b..1040a55 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
 import java.io.IOException;
@@ -62,7 +62,7 @@
   @SuppressWarnings("deprecation")
   private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
       throws UnprocessableEntityException, IOException, ConfigInvalidException {
-    return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().getAccount().getId();
+    return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().account().id();
   }
 
   private static boolean isReviewer(FooterLine candidateFooterLine) {
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 897a5f2..77be665 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -18,7 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index b1acab9..158db1c 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -18,7 +18,14 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -26,23 +33,20 @@
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.mail.HtmlParser;
 import com.google.gerrit.mail.MailComment;
 import com.google.gerrit.mail.MailHeaderParser;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailMetadata;
 import com.google.gerrit.mail.TextParser;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
@@ -54,6 +58,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -83,6 +88,15 @@
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final ImmutableMap<MailComment.CommentType, CommentForValidation.CommentType>
+      MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE =
+          ImmutableMap.of(
+              MailComment.CommentType.CHANGE_MESSAGE,
+                  CommentForValidation.CommentType.CHANGE_MESSAGE,
+              MailComment.CommentType.FILE_COMMENT, CommentForValidation.CommentType.FILE_COMMENT,
+              MailComment.CommentType.INLINE_COMMENT,
+                  CommentForValidation.CommentType.INLINE_COMMENT);
+
   private final Emails emails;
   private final InboundEmailRejectionSender.Factory emailRejectionSender;
   private final RetryHelper retryHelper;
@@ -98,6 +112,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
   public MailProcessor(
@@ -115,7 +130,8 @@
       ApprovalsUtil approvalsUtil,
       CommentAdded commentAdded,
       AccountCache accountCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      DynamicItem<UrlFormatter> urlFormatter,
+      PluginSetContext<CommentValidator> commentValidators) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
@@ -131,6 +147,7 @@
     this.approvalsUtil = approvalsUtil;
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
+    this.commentValidators = commentValidators;
   }
 
   /**
@@ -187,7 +204,7 @@
       logger.atWarning().log("Mail: Account %s doesn't exist. Will delete message.", accountId);
       return;
     }
-    if (!accountState.get().getAccount().isActive()) {
+    if (!accountState.get().account().isActive()) {
       logger.atWarning().log("Mail: Account %s is inactive. Will delete message.", accountId);
       sendRejectionEmail(message, InboundEmailRejectionSender.Error.INACTIVE_ACCOUNT);
       return;
@@ -211,7 +228,7 @@
       throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(new Change.Id(metadata.changeNumber));
+          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -259,7 +276,22 @@
         return;
       }
 
-      Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
+      ImmutableList<CommentForValidation> parsedCommentsForValidation =
+          parsedComments.stream()
+              .map(
+                  comment ->
+                      CommentForValidation.create(
+                          MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE.get(comment.getType()),
+                          comment.getMessage()))
+              .collect(ImmutableList.toImmutableList());
+      ImmutableList<CommentValidationFailure> commentValidationFailures =
+          PublishCommentUtil.findInvalidComments(commentValidators, parsedCommentsForValidation);
+      if (!commentValidationFailures.isEmpty()) {
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.COMMENT_REJECTED);
+        return;
+      }
+
+      Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
       BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
@@ -302,7 +334,7 @@
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
       commentsUtil.putComments(
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Status.PUBLISHED, comments);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Comment.Status.PUBLISHED, comments);
 
       return true;
     }
@@ -330,7 +362,7 @@
       approvalsUtil
           .byPatchSetUser(
               notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
-          .forEach(a -> approvals.put(a.getLabel(), a.getValue()));
+          .forEach(a -> approvals.put(a.label(), a.value()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
@@ -362,7 +394,7 @@
       if (mailComment.getInReplyTo() != null) {
         return psUtil.get(
             ctx.getNotes(),
-            new PatchSet.Id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
+            PatchSet.id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
       }
       return current;
     }
@@ -386,7 +418,7 @@
           commentsUtil.newComment(
               ctx,
               fileName,
-              patchSetForComment.getId(),
+              patchSetForComment.id(),
               (short) side.ordinal(),
               mailComment.getMessage(),
               false,
@@ -399,7 +431,7 @@
         comment.range = mailComment.getInReplyTo().range;
         comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 1017965..a6fb4de 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -25,13 +25,13 @@
 public class AbandonedSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
     @Override
-    AbandonedSender create(Project.NameKey project, Change.Id change);
+    AbandonedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public AbandonedSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "abandon", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "abandon", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 6a4918c..8b3d3f7 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -37,8 +37,8 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments ea, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
-    super(ea, "addkey");
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+    super(args, "addkey");
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -46,8 +46,8 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments ea, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
-    super(ea, "addkey");
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+    super(args, "addkey");
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -79,7 +79,7 @@
   }
 
   public String getEmail() {
-    return user.getAccount().getPreferredEmail();
+    return user.getAccount().preferredEmail();
   }
 
   public String getUserNameEmail() {
diff --git a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
index 22abd9c..96d9483 100644
--- a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
@@ -14,22 +14,22 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
 public class AddReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id id);
+    AddReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public AddReviewerSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 55e2fd4..19c1fa2 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -20,18 +20,18 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
@@ -84,11 +84,11 @@
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
 
-  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) {
-    super(ea, mc, cd.change().getDest());
-    changeData = cd;
-    change = cd.change();
-    emailOnlyAuthors = false;
+  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
+    super(args, messageClass, changeData.change().getDest());
+    this.changeData = changeData;
+    this.change = changeData.change();
+    this.emailOnlyAuthors = false;
   }
 
   @Override
@@ -156,10 +156,10 @@
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
+      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
-          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.getId());
+          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
         } catch (PatchSetInfoNotAvailableException | StorageException err) {
           patchSetInfo = null;
         }
@@ -205,11 +205,8 @@
   }
 
   private void setCommitIdHeader() {
-    if (patchSet != null
-        && patchSet.getRevision() != null
-        && patchSet.getRevision().get() != null
-        && patchSet.getRevision().get().length() > 0) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.getRevision().get());
+    if (patchSet != null) {
+      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
@@ -290,11 +287,11 @@
   /** Get the patch list corresponding to patch set patchSetId of this change. */
   protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
     PatchSet ps;
-    if (patchSetId == patchSet.getPatchSetId()) {
+    if (patchSetId == patchSet.number()) {
       ps = patchSet;
     } else {
       try {
-        ps = args.patchSetUtil.get(changeData.notes(), new PatchSet.Id(change.getId(), patchSetId));
+        ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
       } catch (StorageException e) {
         throw new PatchListNotAvailableException("Failed to get patchSet", e);
       }
@@ -338,7 +335,7 @@
   protected void removeUsersThatIgnoredTheChange() {
     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
       if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.getAccount()));
+        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.account()));
       }
     }
   }
@@ -349,7 +346,7 @@
       return new Watchers();
     }
 
-    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
+    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
     return watch.getWatchers(type, includeWatchersFromNotifyConfig);
   }
 
@@ -415,7 +412,7 @@
       case ALL:
       default:
         if (patchSet != null) {
-          authors.add(patchSet.getUploader());
+          authors.add(patchSet.uploader());
         }
         if (patchSetInfo != null) {
           if (patchSetInfo.getAuthor().getAccount() != null) {
@@ -465,8 +462,8 @@
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
-    patchSetData.put("patchSetId", patchSet.getPatchSetId());
-    patchSetData.put("refName", patchSet.getRefName());
+    patchSetData.put("patchSetId", patchSet.number());
+    patchSetData.put("refName", patchSet.refName());
     soyContext.put("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
@@ -476,7 +473,7 @@
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
     footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
-    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.getPatchSetId());
+    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
       footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 7b11ce6..48d342e 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -17,21 +17,21 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.FilenameComparator;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.KeyUtil;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -41,7 +41,6 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -55,7 +54,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
@@ -65,7 +63,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id id);
+    CommentSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private class FileCommentGroup {
@@ -113,12 +111,12 @@
 
   @Inject
   public CommentSender(
-      EmailArguments ea,
+      EmailArguments args,
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id) {
-    super(ea, "comment", newChangeData(ea, project, id));
+      @Assisted Change.Id changeId) {
+    super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
@@ -128,14 +126,6 @@
 
   public void setComments(List<Comment> comments) {
     inlineComments = comments;
-
-    Set<String> paths = new HashSet<>();
-    for (Comment c : comments) {
-      if (!Patch.isMagic(c.key.filename)) {
-        paths.add(c.key.filename);
-      }
-    }
-    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
   }
 
   public void setPatchSetComment(String comment) {
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 9895e07..1f58abb 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -34,18 +34,18 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id id);
+    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final PermissionBackend permissionBackend;
 
   @Inject
   public CreateChangeSender(
-      EmailArguments ea,
+      EmailArguments args,
       PermissionBackend permissionBackend,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id) {
-    super(ea, newChangeData(ea, project, id));
+      @Assisted Change.Id changeId) {
+    super(args, newChangeData(args, project, changeId));
     this.permissionBackend = permissionBackend;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index 39e21a5..c9bb1e4 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -38,8 +38,8 @@
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments ea, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
-    super(ea, "deletekey");
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+    super(args, "deletekey");
     this.user = user;
     this.gpgKeyFingerprints = Collections.emptyList();
     this.sshKey = sshKey;
@@ -47,8 +47,10 @@
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments ea, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeyFingerprints) {
-    super(ea, "deletekey");
+      EmailArguments args,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeyFingerprints) {
+    super(args, "deletekey");
     this.user = user;
     this.gpgKeyFingerprints = gpgKeyFingerprints;
     this.sshKey = null;
@@ -75,7 +77,7 @@
   }
 
   public String getEmail() {
-    return user.getAccount().getPreferredEmail();
+    return user.getAccount().preferredEmail();
   }
 
   public String getUserNameEmail() {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index f941acc..4f42679 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -36,13 +36,13 @@
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id change);
+    DeleteReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public DeleteReviewerSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "deleteReviewer", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "deleteReviewer", newChangeData(args, project, changeId));
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 195d53d..76f9b81 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -25,13 +25,13 @@
 public class DeleteVoteSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
     @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id change);
+    DeleteVoteSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   protected DeleteVoteSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "deleteVote", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "deleteVote", newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index fe2f74b..ede5765 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -75,7 +75,7 @@
 
   final ChangeQueryBuilder queryBuilder;
   final ChangeData.Factory changeDataFactory;
-  final SoyTofu soyTofu;
+  final SoySauce soySauce;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final Provider<InternalAccountQuery> accountQueryProvider;
@@ -105,7 +105,7 @@
       AllProjectsName allProjectsName,
       ChangeQueryBuilder queryBuilder,
       ChangeData.Factory changeDataFactory,
-      @MailTemplates SoyTofu soyTofu,
+      @MailTemplates SoySauce soySauce,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
       SitePaths site,
@@ -134,7 +134,7 @@
     this.allProjectsName = allProjectsName;
     this.queryBuilder = queryBuilder;
     this.changeDataFactory = changeDataFactory;
-    this.soyTofu = soyTofu;
+    this.soySauce = soySauce;
     this.settings = settings;
     this.sshAddresses = sshAddresses;
     this.site = site;
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 5baabe9..61fa50d 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index b77909e..1bf1ff1 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -17,8 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -123,9 +123,9 @@
     public Address from(Account.Id fromId) {
       String senderName;
       if (fromId != null) {
-        Optional<Account> a = accountCache.get(fromId).map(AccountState::getAccount);
-        String fullName = a.map(Account::getFullName).orElse(null);
-        String userEmail = a.map(Account::getPreferredEmail).orElse(null);
+        Optional<Account> a = accountCache.get(fromId).map(AccountState::account);
+        String fullName = a.map(Account::fullName).orElse(null);
+        String userEmail = a.map(Account::preferredEmail).orElse(null);
         if (canRelay(userEmail)) {
           return new Address(fullName, userEmail);
         }
@@ -208,8 +208,7 @@
       final String senderName;
 
       if (fromId != null) {
-        String fullName =
-            accountCache.get(fromId).map(a -> a.getAccount().getFullName()).orElse(null);
+        String fullName = accountCache.get(fromId).map(a -> a.account().fullName()).orElse(null);
         if (fullName == null || "".equals(fullName)) {
           fullName = anonymousCowardName;
         }
@@ -235,7 +234,7 @@
       byte[] bytes = hash.digest(data.getBytes(UTF_8));
       return Base64.encodeBase64URLSafeString(bytes);
     } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("No MD5 available", e);
+      throw new IllegalStateException("No MD5 available", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index ca332ff..2db2d6d 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -31,8 +31,8 @@
 
   @AssistedInject
   public HttpPasswordUpdateSender(
-      EmailArguments ea, @Assisted IdentifiedUser user, @Assisted String operation) {
-    super(ea, "HttpPasswordUpdate");
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+    super(args, "HttpPasswordUpdate");
     this.user = user;
     this.operation = operation;
   }
@@ -59,7 +59,7 @@
   }
 
   public String getEmail() {
-    return user.getAccount().getPreferredEmail();
+    return user.getAccount().preferredEmail();
   }
 
   public String getUserNameEmail() {
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index b5d384d..110f26a 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -32,7 +32,8 @@
     PARSING_ERROR,
     INACTIVE_ACCOUNT,
     UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION;
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED
   }
 
   public interface Factory {
@@ -45,8 +46,11 @@
 
   @Inject
   public InboundEmailRejectionSender(
-      EmailArguments ea, @Assisted Address to, @Assisted String threadId, @Assisted Error reason) {
-    super(ea, "error");
+      EmailArguments args,
+      @Assisted Address to,
+      @Assisted String threadId,
+      @Assisted Error reason) {
+    super(args, "error");
     this.to = requireNonNull(to);
     this.threadId = requireNonNull(threadId);
     this.reason = requireNonNull(reason);
diff --git a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
similarity index 75%
rename from java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
rename to java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 3bb44c7..92220eb 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -17,22 +17,23 @@
 import com.google.common.io.CharStreams;
 import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import com.google.template.soy.shared.SoyAstCache;
-import com.google.template.soy.tofu.SoyTofu;
 import java.io.IOException;
 import java.io.Reader;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
-/** Configures Soy Tofu object for rendering email templates. */
+/** Configures Soy Sauce object for rendering email templates. */
 @Singleton
-public class MailSoyTofuProvider implements Provider<SoyTofu> {
+public class MailSoySauceProvider implements Provider<SoySauce> {
 
   // Note: will fail to construct the tofu object if this array is empty.
   private static final String[] TEMPLATES = {
@@ -80,24 +81,32 @@
 
   private final SitePaths site;
   private final SoyAstCache cache;
+  private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoyTofuProvider(SitePaths site, SoyAstCache cache) {
+  MailSoySauceProvider(
+      SitePaths site,
+      SoyAstCache cache,
+      PluginSetContext<MailSoyTemplateProvider> templateProviders) {
     this.site = site;
     this.cache = cache;
+    this.templateProviders = templateProviders;
   }
 
   @Override
-  public SoyTofu get() throws ProvisionException {
+  public SoySauce get() throws ProvisionException {
     SoyFileSet.Builder builder = SoyFileSet.builder();
     builder.setSoyAstCache(cache);
     for (String name : TEMPLATES) {
-      addTemplate(builder, name);
+      addTemplate(builder, "com/google/gerrit/server/mail/", name);
     }
-    return builder.build().compileToTofu();
+    templateProviders.runEach(
+        e -> e.getFileNames().forEach(p -> addTemplate(builder, e.getPath(), p)));
+    return builder.build().compileTemplates();
   }
 
-  private void addTemplate(SoyFileSet.Builder builder, String name) throws ProvisionException {
+  private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
+      throws ProvisionException {
     // Load as a file in the mail templates directory if present.
     Path tmpl = site.mail_dir.resolve(name);
     if (Files.isRegularFile(tmpl)) {
@@ -115,7 +124,9 @@
     }
 
     // Otherwise load the template as a resource.
-    String resourcePath = "com/google/gerrit/server/mail/" + name;
-    builder.add(Resources.getResource(resourcePath));
+    if (!resourcePath.endsWith("/")) {
+      resourcePath += "/";
+    }
+    builder.add(Resources.getResource(resourcePath + name));
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoyTemplateProvider.java b/java/com/google/gerrit/server/mail/send/MailSoyTemplateProvider.java
new file mode 100644
index 0000000..3a6ff64
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MailSoyTemplateProvider.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Set;
+
+/**
+ * Extension point to provide soy templates that should be registered so that they can be used for
+ * sending emails from a plugin.
+ */
+@ExtensionPoint
+public interface MailSoyTemplateProvider {
+  /**
+   * Return the name of the resource path that contains the soy template files that are returned by
+   * {@link #getFileNames()}.
+   *
+   * @return resource path of the templates
+   */
+  String getPath();
+
+  /**
+   * Return the names of the soy template files.
+   *
+   * <p>These files are expected to exist in the resource path that is returned by {@link
+   * #getPath()}.
+   *
+   * @return names of the template files, including the {@code .soy} file extension
+   */
+  Set<String> getFileNames();
+}
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cbc4117..b28a4dc 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -19,12 +19,12 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,15 +32,15 @@
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id id);
+    MergedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final LabelTypes labelTypes;
 
   @Inject
   public MergedSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "merged", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
   }
 
@@ -69,15 +69,15 @@
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.getId(), null, null)) {
-        LabelType lt = labelTypes.byLabel(ca.getLabelId());
+          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
+        LabelType lt = labelTypes.byLabel(ca.labelId());
         if (lt == null) {
           continue;
         }
-        if (ca.getValue() > 0) {
-          pos.put(ca.getAccountId(), lt.getName(), ca);
-        } else if (ca.getValue() < 0) {
-          neg.put(ca.getAccountId(), lt.getName(), ca);
+        if (ca.value() > 0) {
+          pos.put(ca.accountId(), lt.getName(), ca);
+        } else if (ca.value() < 0) {
+          neg.put(ca.accountId(), lt.getName(), ca);
         }
       }
 
@@ -117,7 +117,7 @@
         } else {
           txt.append(lt.getName());
           txt.append('=');
-          txt.append(LabelValue.formatValue(ca.getValue()));
+          txt.append(LabelValue.formatValue(ca.value()));
         }
       }
       txt.append('\n');
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index a31596b..83c3a94 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -32,8 +32,8 @@
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final Set<Address> extraCCByEmail = new HashSet<>();
 
-  protected NewChangeSender(EmailArguments ea, ChangeData cd) {
-    super(ea, "newchange", cd);
+  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
+    super(args, "newchange", changeData);
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 9952ba6..0fb5c6f 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -17,13 +17,13 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import java.util.HashMap;
@@ -33,10 +33,10 @@
 public abstract class NotificationEmail extends OutgoingEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected Branch.NameKey branch;
+  protected BranchNameKey branch;
 
-  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
-    super(ea, mc);
+  protected NotificationEmail(EmailArguments args, String messageClass, BranchNameKey branch) {
+    super(args, messageClass);
     this.branch = branch;
   }
 
@@ -50,7 +50,7 @@
     // Set a reasonable list id so that filters can be used to sort messages
     setHeader(
         "List-Id",
-        "<gerrit-" + branch.getParentKey().get().replace('/', '-') + "." + getGerritHost() + ">");
+        "<gerrit-" + branch.project().get().replace('/', '-') + "." + getGerritHost() + ">");
     if (getSettingsUrl() != null) {
       setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
     }
@@ -104,7 +104,7 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
 
-    String projectName = branch.getParentKey().get();
+    String projectName = branch.project().get();
     soyContext.put("projectName", projectName);
     // shortProjectName is the project name with the path abbreviated.
     soyContext.put("shortProjectName", getShortProjectName(projectName));
@@ -118,11 +118,11 @@
     soyContextEmailData.put("sshHost", getSshHost());
 
     Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.getShortName());
+    branchData.put("shortName", branch.shortName());
     soyContext.put("branch", branchData);
 
-    footers.add(MailHeader.PROJECT.withDelimiter() + branch.getParentKey().get());
-    footers.add(MailHeader.BRANCH.withDelimiter() + branch.getShortName());
+    footers.add(MailHeader.PROJECT.withDelimiter() + branch.project().get());
+    footers.add(MailHeader.BRANCH.withDelimiter() + branch.shortName());
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 3bb710b..4f214ed 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -20,6 +20,9 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -28,14 +31,12 @@
 import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.EmailHeader.AddressList;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
@@ -55,6 +56,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class OutgoingEmail {
+  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template.";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected String messageClass;
@@ -71,10 +73,10 @@
   protected Account.Id fromId;
   protected NotifyResolver.Result notify = NotifyResolver.Result.all();
 
-  protected OutgoingEmail(EmailArguments ea, String mc) {
-    args = ea;
-    messageClass = mc;
-    headers = new LinkedHashMap<>();
+  protected OutgoingEmail(EmailArguments args, String messageClass) {
+    this.args = args;
+    this.messageClass = messageClass;
+    this.headers = new LinkedHashMap<>();
   }
 
   public void setFrom(Account.Id id) {
@@ -119,7 +121,7 @@
       if (fromId != null) {
         Optional<AccountState> fromUser = args.accountCache.get(fromId);
         if (fromUser.isPresent()) {
-          GeneralPreferencesInfo senderPrefs = fromUser.get().getGeneralPreferences();
+          GeneralPreferencesInfo senderPrefs = fromUser.get().generalPreferences();
           if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
             // If we are impersonating a user, make sure they receive a CC of
             // this message so they can always review and audit what we sent
@@ -130,7 +132,7 @@
             // If they don't want a copy, but we queued one up anyway,
             // drop them from the recipient lists.
             //
-            removeUser(fromUser.get().getAccount());
+            removeUser(fromUser.get().account());
           }
         }
       }
@@ -140,14 +142,14 @@
       for (Account.Id id : rcptTo) {
         Optional<AccountState> thisUser = args.accountCache.get(id);
         if (thisUser.isPresent()) {
-          Account thisUserAccount = thisUser.get().getAccount();
-          GeneralPreferencesInfo prefs = thisUser.get().getGeneralPreferences();
+          Account thisUserAccount = thisUser.get().account();
+          GeneralPreferencesInfo prefs = thisUser.get().generalPreferences();
           if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
             removeUser(thisUserAccount);
           } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
             removeUser(thisUserAccount);
             smtpRcptToPlaintextOnly.add(
-                new Address(thisUserAccount.getFullName(), thisUserAccount.getPreferredEmail()));
+                new Address(thisUserAccount.fullName(), thisUserAccount.preferredEmail()));
           }
         }
         if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
@@ -262,10 +264,10 @@
 
   protected String getFromLine() {
     StringBuilder f = new StringBuilder();
-    Optional<Account> account = args.accountCache.get(fromId).map(AccountState::getAccount);
+    Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
     if (account.isPresent()) {
-      String name = account.get().getFullName();
-      String email = account.get().getPreferredEmail();
+      String name = account.get().fullName();
+      String email = account.get().preferredEmail();
       if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
         f.append("From");
         if (name != null && !name.isEmpty()) {
@@ -333,17 +335,17 @@
   }
 
   /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(Account.Id accountId) {
+  protected String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return args.gerritPersonIdent.getName();
     }
 
-    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount);
+    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
     String name = null;
     if (account.isPresent()) {
-      name = account.get().getFullName();
+      name = account.get().fullName();
       if (name == null) {
-        name = account.get().getPreferredEmail();
+        name = account.get().preferredEmail();
       }
     }
     if (name == null) {
@@ -359,11 +361,18 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  protected String getNameEmailFor(Account.Id accountId) {
-    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount);
+  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return args.gerritPersonIdent.getName()
+          + " <"
+          + args.gerritPersonIdent.getEmailAddress()
+          + ">";
+    }
+
+    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
     if (account.isPresent()) {
-      String name = account.get().getFullName();
-      String email = account.get().getPreferredEmail();
+      String name = account.get().fullName();
+      String email = account.get().preferredEmail();
       if (name != null && email != null) {
         return name + " <" + email + ">";
       } else if (name != null) {
@@ -388,9 +397,9 @@
       return null;
     }
 
-    Account account = accountState.get().getAccount();
-    String name = account.getFullName();
-    String email = account.getPreferredEmail();
+    Account account = accountState.get().account();
+    String name = account.fullName();
+    String email = account.preferredEmail();
     if (name != null && email != null) {
       return name + " <" + email + ">";
     } else if (email != null) {
@@ -398,7 +407,7 @@
     } else if (name != null) {
       return name;
     }
-    return accountState.get().getUserName().orElse(null);
+    return accountState.get().userName().orElse(null);
   }
 
   protected boolean shouldSendMessage() {
@@ -520,17 +529,17 @@
   }
 
   private Address toAddress(Account.Id id) {
-    Optional<Account> accountState = args.accountCache.get(id).map(AccountState::getAccount);
+    Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
     if (!accountState.isPresent()) {
       return null;
     }
 
     Account account = accountState.get();
-    String e = account.getPreferredEmail();
+    String e = account.preferredEmail();
     if (!account.isActive() || e == null) {
       return null;
     }
-    return new Address(account.getFullName(), e);
+    return new Address(account.fullName(), e);
   }
 
   protected void setupSoyContext() {
@@ -552,24 +561,23 @@
     return args.instanceNameProvider.get();
   }
 
-  private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
-    return args.soyTofu
-        .newRenderer("com.google.gerrit.server.mail.template." + name)
-        .setContentKind(kind)
-        .setData(soyContext)
-        .render();
-  }
-
+  /** Renders a soy template of kind="text". */
   protected String textTemplate(String name) {
-    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
+    return configureRenderer(name).renderText().get();
   }
 
+  /** Renders a soy template of kind="html". */
   protected String soyHtmlTemplate(String name) {
-    return soyTemplate(name, SanitizedContent.ContentKind.HTML);
+    return configureRenderer(name).renderHtml().get().toString();
+  }
+
+  /** Configures a soy renderer for the given template name and rendering data map. */
+  private SoySauce.Renderer configureRenderer(String templateName) {
+    return args.soySauce.renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName).setData(soyContext);
   }
 
   protected void removeUser(Account user) {
-    String fromEmail = user.getPreferredEmail();
+    String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().getEmail().equals(fromEmail)) {
         j.remove();
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0a468b4..2530d7e 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -19,12 +19,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
@@ -66,9 +66,8 @@
     Set<Account.Id> projectWatchers = new HashSet<>();
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
-      Account.Id accountId = a.getAccount().getId();
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
-          a.getProjectWatches().entrySet()) {
+      Account.Id accountId = a.account().id();
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
         if (project.equals(e.getKey().project())
             && add(matching, accountId, e.getKey(), e.getValue(), type)) {
           // We only want to prevent matching All-Projects if this filter hits
@@ -78,10 +77,9 @@
     }
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
-          a.getProjectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
         if (args.allProjectsName.equals(e.getKey().project())) {
-          Account.Id accountId = a.getAccount().getId();
+          Account.Id accountId = a.account().id();
           if (!projectWatchers.contains(accountId)) {
             add(matching, accountId, e.getKey(), e.getValue(), type);
           }
@@ -97,9 +95,9 @@
       for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
         if (nc.isNotify(type)) {
           try {
-            add(matching, nc);
+            add(matching, state.getNameKey(), nc);
           } catch (QueryParseException e) {
-            logger.atWarning().log(
+            logger.atInfo().log(
                 "Project %s has invalid notify %s filter \"%s\": %s",
                 state.getName(), nc.getName(), nc.getFilter(), e.getMessage());
           }
@@ -146,17 +144,27 @@
     }
   }
 
-  private void add(Watchers matching, NotifyConfig nc) throws QueryParseException {
-    for (GroupReference ref : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(ref.getUUID());
+  private void add(Watchers matching, Project.NameKey projectName, NotifyConfig nc)
+      throws QueryParseException {
+    logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
+    for (GroupReference groupRef : nc.getGroups()) {
+      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
       if (filterMatch(user, nc.getFilter())) {
-        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
+        deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
+        logger.atFine().log("Added watchers for group %s", groupRef);
+      } else {
+        logger.atFine().log("The filter did not match for group %s; skip notification", groupRef);
       }
     }
 
     if (!nc.getAddresses().isEmpty()) {
       if (filterMatch(null, nc.getFilter())) {
         matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
+        logger.atFine().log("Added watchers for these addresses: %s", nc.getAddresses());
+      } else {
+        logger.atFine().log(
+            "The filter did not match; skip notification for these addresses: %s",
+            nc.getAddresses());
       }
     }
   }
@@ -172,19 +180,24 @@
       AccountGroup.UUID uuid = q.remove(q.size() - 1);
       GroupDescription.Basic group = args.groupBackend.get(uuid);
       if (group == null) {
+        logger.atFine().log("group %s not found, skip notification", uuid);
         continue;
       }
       if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
         // If the group has an email address, do not expand membership.
         matching.emails.add(new Address(group.getEmailAddress()));
+        logger.atFine().log(
+            "notify group email address %s; skip expanding to members", group.getEmailAddress());
         continue;
       }
 
       if (!(group instanceof GroupDescription.Internal)) {
         // Non-internal groups cannot be expanded by the server.
+        logger.atFine().log("group %s is not an internal group, skip notification", uuid);
         continue;
       }
 
+      logger.atFine().log("adding the members of group %s as watchers", uuid);
       GroupDescription.Internal ig = (GroupDescription.Internal) group;
       matching.accounts.addAll(ig.getMembers());
       for (AccountGroup.UUID m : ig.getSubgroups()) {
@@ -201,8 +214,9 @@
       ProjectWatchKey key,
       Set<NotifyType> watchedTypes,
       NotifyType type) {
-    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
+    logger.atFine().log("Checking project watch %s of account %s", key, accountId);
 
+    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
     try {
       if (filterMatch(user, key.filter())) {
         // If we are set to notify on this type, add the user.
@@ -210,10 +224,14 @@
         if (watchedTypes.contains(type)) {
           matching.bcc.accounts.add(accountId);
         }
+        logger.atFine().log("Added account %s as watcher", accountId);
         return true;
       }
+      logger.atFine().log("The filter did not match for account %s; skip notification", accountId);
     } catch (QueryParseException e) {
       // Ignore broken filter expressions.
+      logger.atInfo().log(
+          "Account %s has invalid filter in project watch %s: %s", accountId, key, e.getMessage());
     }
     return false;
   }
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index 436736b..91d8e81 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -36,14 +36,14 @@
 
   @Inject
   public RegisterNewEmailSender(
-      EmailArguments ea,
-      EmailTokenVerifier etv,
+      EmailArguments args,
+      EmailTokenVerifier tokenVerifier,
       IdentifiedUser callingUser,
       @Assisted final String address) {
-    super(ea, "registernewemail");
-    tokenVerifier = etv;
-    user = callingUser;
-    addr = address;
+    super(args, "registernewemail");
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 30bcdeb..909c52a 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,7 +32,7 @@
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id id);
+    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
@@ -40,8 +40,8 @@
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "newpatchset", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "newpatchset", newChangeData(args, project, changeId));
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
index 960c3a8..c765430 100644
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 
 /** Alert a user to a reply to a change, usually commentary made during review. */
@@ -26,8 +26,8 @@
     T create(Project.NameKey project, Change.Id id);
   }
 
-  protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd) {
-    super(ea, mc, cd);
+  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
+    super(args, messageClass, changeData);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index 0d998aa..2a4c556 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -25,13 +25,13 @@
 public class RestoredSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
     @Override
-    RestoredSender create(Project.NameKey project, Change.Id id);
+    RestoredSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public RestoredSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "restore", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "restore", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index 48b5d99..dadd0d2 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -24,13 +24,13 @@
 /** Send notice about a change being reverted. */
 public class RevertedSender extends ReplyToChangeSender {
   public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id id);
+    RevertedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public RevertedSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id) {
-    super(ea, "revert", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "revert", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
index a120769..850f775 100644
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -14,28 +14,28 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 public class SetAssigneeSender extends ChangeEmail {
   public interface Factory {
-    SetAssigneeSender create(Project.NameKey project, Change.Id id, Account.Id assignee);
+    SetAssigneeSender create(Project.NameKey project, Change.Id changeId, Account.Id assignee);
   }
 
   private final Account.Id assignee;
 
   @Inject
   public SetAssigneeSender(
-      EmailArguments ea,
+      EmailArguments args,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
+      @Assisted Change.Id changeId,
       @Assisted Account.Id assignee) {
-    super(ea, "setassignee", newChangeData(ea, project, id));
+    super(args, "setassignee", newChangeData(args, project, changeId));
     this.assignee = assignee;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 1c6057d..0acf20e 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -20,11 +20,12 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -49,8 +50,8 @@
     public final ChangeNoteJson changeNoteJson;
     public final GitRepositoryManager repoManager;
     public final AllUsersName allUsers;
-    public final LegacyChangeNoteRead legacyChangeNoteRead;
     public final NoteDbMetrics metrics;
+    public final String serverId;
 
     // Providers required to avoid dependency cycles.
 
@@ -62,16 +63,16 @@
         GitRepositoryManager repoManager,
         AllUsersName allUsers,
         ChangeNoteJson changeNoteJson,
-        LegacyChangeNoteRead legacyChangeNoteRead,
         NoteDbMetrics metrics,
-        Provider<ChangeNotesCache> cache) {
+        Provider<ChangeNotesCache> cache,
+        @GerritServerId String serverId) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
-      this.legacyChangeNoteRead = legacyChangeNoteRead;
       this.changeNoteJson = changeNoteJson;
       this.metrics = metrics;
       this.cache = cache;
+      this.serverId = serverId;
     }
   }
 
@@ -139,7 +140,7 @@
     if (args.failOnLoadForTest.get()) {
       throw new StorageException("Reading from NoteDb is disabled");
     }
-    try (Timer1.Context timer = args.metrics.readLatency.start(CHANGES);
+    try (Timer1.Context<NoteDbTable> timer = args.metrics.readLatency.start(CHANGES);
         Repository repo = args.repoManager.openRepository(getProjectName());
         // Call openHandle even if reading is disabled, to trigger
         // auto-rebuilding before this object may get passed to a ChangeUpdate.
@@ -153,6 +154,7 @@
     return self();
   }
 
+  @Nullable
   protected ObjectId readRef(Repository repo) throws IOException {
     Ref ref = repo.getRefDatabase().exactRef(getRefName());
     return ref != null ? ref.getObjectId() : null;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index daef7e7..ee3ccd6 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -19,11 +19,11 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
@@ -147,7 +147,7 @@
   }
 
   public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null || psId.getParentKey().equals(getId()));
+    checkArgument(psId == null || psId.changeId().equals(getId()));
     this.psId = psId;
   }
 
@@ -193,6 +193,14 @@
   }
 
   /**
+   * Whether to allow bypassing the check that an update does not exceed the max update count on an
+   * object.
+   */
+  protected boolean bypassMaxUpdates() {
+    return false;
+  }
+
+  /**
    * Apply this update to the given inserter.
    *
    * @param rw walk for reading back any objects needed for the update.
@@ -267,7 +275,7 @@
   }
 
   protected void verifyComment(Comment c) {
-    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(c.getCommitId() != null, "commit ID required for comment: %s", c);
     checkArgument(
         c.author.getId().equals(getAccountId()),
         "The author for the following comment does not match the author of this %s (%s): %s",
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
new file mode 100644
index 0000000..112f687
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.PushCertificate;
+
+/**
+ * Performs an update on {@code All-Users} asynchronously if required. No-op in case no updates were
+ * scheduled for asynchronous execution.
+ */
+public class AllUsersAsyncUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExecutorService executor;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+
+  private PersonIdent serverIdent;
+
+  @Inject
+  AllUsersAsyncUpdate(
+      @FanOutExecutor ExecutorService executor,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager) {
+    this.executor = executor;
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+  }
+
+  void setDraftUpdates(ListMultimap<String, ChangeDraftUpdate> draftUpdates) {
+    checkState(isEmpty(), "attempted to set draft comment updates for async execution twice");
+    boolean allPublishOnly =
+        draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+    checkState(allPublishOnly, "not all updates can be run asynchronously");
+    // Add deep copies to avoid any threading issues.
+    for (Map.Entry<String, ChangeDraftUpdate> entry : draftUpdates.entries()) {
+      this.draftUpdates.put(entry.getKey(), entry.getValue().copy());
+    }
+    if (draftUpdates.size() > 0) {
+      // Save the PersonIdent for later so that we get consistent time stamps in the commit and ref
+      // log.
+      serverIdent = Iterables.get(draftUpdates.entries(), 0).getValue().serverIdent;
+    }
+  }
+
+  /** Returns true if no operations should be performed on the repo. */
+  boolean isEmpty() {
+    return draftUpdates.isEmpty();
+  }
+
+  /** Executes repository update asynchronously. No-op in case no updates were scheduled. */
+  void execute(PersonIdent refLogIdent, String refLogMessage, PushCertificate pushCert) {
+    if (isEmpty()) {
+      return;
+    }
+
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
+                allUsersRepo.addUpdates(draftUpdates);
+                allUsersRepo.flush();
+                BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
+                bru.setPushCertificate(pushCert);
+                if (refLogMessage != null) {
+                  bru.setRefLogMessage(refLogMessage, false);
+                } else {
+                  bru.setRefLogMessage(
+                      firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
+                      false);
+                }
+                bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
+                bru.setAtomic(true);
+                allUsersRepo.cmds.addTo(bru);
+                bru.setAllowNonFastForwards(true);
+                RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+              } catch (IOException e) {
+                logger.atSevere().withCause(e).log(
+                    "Failed to delete draft comments asynchronously after publishing them");
+              }
+            });
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 8773d9b..05fdee9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.assistedinject.Assisted;
@@ -35,7 +34,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
-import java.util.HashSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -74,19 +73,25 @@
 
   @AutoValue
   abstract static class Key {
-    abstract String revId();
+    abstract ObjectId commitId();
 
     abstract Comment.Key key();
   }
 
+  enum DeleteReason {
+    DELETED,
+    PUBLISHED,
+    FIXED
+  }
+
   private static Key key(Comment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
+    return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
   private List<Comment> put = new ArrayList<>();
-  private Set<Key> delete = new HashSet<>();
+  private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
   private ChangeDraftUpdate(
@@ -117,40 +122,92 @@
   }
 
   public void putComment(Comment c) {
+    checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
   }
 
-  public void deleteComment(Comment c) {
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
+   */
+  public void markCommentPublished(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
-    delete.add(key(c));
+    delete.put(key(c), DeleteReason.PUBLISHED);
   }
 
-  public void deleteComment(String revId, Comment.Key key) {
-    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
+   */
+  public void deleteComment(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
+    verifyComment(c);
+    delete.put(key(c), DeleteReason.DELETED);
+  }
+
+  /**
+   * Marks a comment for deletion. Called when the comment should have been deleted previously, but
+   * wasn't, so we're fixing it up.
+   */
+  public void deleteComment(ObjectId commitId, Comment.Key key) {
+    Key commentKey = new AutoValue_ChangeDraftUpdate_Key(commitId, key);
+    checkState(!delete.containsKey(commentKey), "comment already marked for deletion");
+    delete.put(commentKey, DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns true if all we do in this operations is deletes caused by publishing or fixing up
+   * comments.
+   */
+  public boolean canRunAsync() {
+    return put.isEmpty()
+        && delete.values().stream()
+            .allMatch(r -> r == DeleteReason.PUBLISHED || r == DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns a copy of the current {@link ChangeDraftUpdate} that contains references to all
+   * deletions. Copying of {@link ChangeDraftUpdate} is only allowed if it contains no new comments.
+   */
+  ChangeDraftUpdate copy() {
+    checkState(
+        put.isEmpty(),
+        "copying ChangeDraftUpdate is allowed only if it doesn't contain new comments");
+    ChangeDraftUpdate clonedUpdate =
+        new ChangeDraftUpdate(
+            authorIdent,
+            draftsProject,
+            noteUtil,
+            new Change(getChange()),
+            accountId,
+            realAccountId,
+            authorIdent,
+            when);
+    clonedUpdate.delete.putAll(delete);
+    return clonedUpdate;
   }
 
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> updatedCommits = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (Comment c : put) {
-      if (!delete.contains(key(c))) {
-        cache.get(new RevId(c.revId)).putComment(c);
+      if (!delete.keySet().contains(key(c))) {
+        cache.get(c.getCommitId()).putComment(c);
       }
     }
-    for (Key k : delete) {
-      cache.get(new RevId(k.revId())).deleteComment(k.key());
+    for (Key k : delete.keySet()) {
+      cache.get(k.commitId()).deleteComment(k.key());
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedCommits.add(e.getKey());
+      ObjectId id = e.getKey();
       byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
@@ -204,12 +261,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(),
-        noteUtil.getLegacyChangeNoteRead(),
-        getId(),
-        rw.getObjectReader(),
-        noteMap,
-        PatchLineComment.Status.DRAFT);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.DRAFT);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 628dfd2..b221ef5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import java.util.Date;
@@ -63,22 +63,13 @@
   static final String UNRESOLVED = "Unresolved";
   static final String TAG = FOOTER_TAG.getName();
 
-  private final LegacyChangeNoteRead legacyChangeNoteRead;
   private final ChangeNoteJson changeNoteJson;
   private final String serverId;
 
   @Inject
-  public ChangeNoteUtil(
-      ChangeNoteJson changeNoteJson,
-      LegacyChangeNoteRead legacyChangeNoteRead,
-      @GerritServerId String serverId) {
+  public ChangeNoteUtil(ChangeNoteJson changeNoteJson, @GerritServerId String serverId) {
     this.serverId = serverId;
     this.changeNoteJson = changeNoteJson;
-    this.legacyChangeNoteRead = legacyChangeNoteRead;
-  }
-
-  public LegacyChangeNoteRead getLegacyChangeNoteRead() {
-    return legacyChangeNoteRead;
   }
 
   public ChangeNoteJson getChangeNoteJson() {
@@ -96,8 +87,8 @@
   @VisibleForTesting
   public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
     return new PersonIdent(
-        "Gerrit User " + author.getId(),
-        author.getId().get() + "@" + serverId,
+        "Gerrit User " + author.id(),
+        author.id().get() + "@" + serverId,
         when,
         serverIdent.getTimeZone());
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 764d41d..b7ce2a8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,18 +16,21 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
@@ -36,18 +39,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -63,7 +66,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Predicate;
@@ -78,7 +80,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
-      Ordering.from(comparing(PatchSetApproval::getGranted));
+      Ordering.from(comparing(PatchSetApproval::granted));
 
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
       Ordering.from(comparing(ChangeMessage::getWrittenOn));
@@ -127,7 +129,7 @@
 
     public static Change newChange(Project.NameKey project, Change.Id changeId) {
       return new Change(
-          null, changeId, null, new Branch.NameKey(project, "INVALID_NOTE_DB_ONLY"), null);
+          null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
     }
 
     public ChangeNotes create(Project.NameKey project, Change.Id changeId) {
@@ -209,6 +211,7 @@
       return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
+    @Nullable
     private ChangeNotesResult scanOneChange(Project.NameKey project, ScanResult sr, Change.Id id) {
       if (!sr.fromMetaRefs().contains(id)) {
         // Stray patch set refs can happen due to normal error conditions, e.g. failed
@@ -217,10 +220,15 @@
       }
 
       // TODO(dborowitz): See discussion in BatchUpdate#newChangeContext.
-      Change change = ChangeNotes.Factory.newChange(project, id);
-
-      logger.atFine().log("adding change %s found in project %s", id, project);
-      return toResult(change);
+      try {
+        Change change = ChangeNotes.Factory.newChange(project, id);
+        logger.atFine().log("adding change %s found in project %s", id, project);
+        return toResult(change);
+      } catch (InvalidServerIdException ise) {
+        logger.atWarning().withCause(ise).log(
+            "skipping change %d in project %s because of an invalid server id", id.get(), project);
+        return null;
+      }
     }
 
     @Nullable
@@ -331,9 +339,7 @@
     if (patchSets == null) {
       ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
           ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
-      for (Map.Entry<PatchSet.Id, PatchSet> e : state.patchSets()) {
-        b.put(e.getKey(), new PatchSet(e.getValue()));
-      }
+      b.putAll(state.patchSets());
       patchSets = b.build();
     }
     return patchSets;
@@ -341,12 +347,7 @@
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
     if (approvals == null) {
-      ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> b =
-          ImmutableListMultimap.builder();
-      for (Map.Entry<PatchSet.Id, PatchSetApproval> e : state.approvals()) {
-        b.put(e.getKey(), new PatchSetApproval(e.getValue()));
-      }
-      approvals = b.build();
+      approvals = ImmutableListMultimap.copyOf(state.approvals());
     }
     return approvals;
   }
@@ -374,9 +375,24 @@
     return state.reviewerUpdates();
   }
 
-  /** @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. */
+  /**
+   * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
+   *     order of the set is the order in which they were assigned.
+   */
   public ImmutableSet<Account.Id> getPastAssignees() {
-    return state.pastAssignees();
+    return Lists.reverse(state.assigneeUpdates()).stream()
+        .map(AssigneeStatusUpdate::currentAssignee)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * @return an ImmutableList of AssigneeStatusUpdate of all the updates to the assignee field to
+   *     this change. The order of the list is from most recent updates to least recent.
+   */
+  public ImmutableList<AssigneeStatusUpdate> getAssigneeUpdates() {
+    return state.assigneeUpdates();
   }
 
   /** @return a ImmutableSet of all hashtags for this change sorted in alphabetical order. */
@@ -403,7 +419,7 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return state.publishedComments();
   }
 
@@ -418,11 +434,15 @@
     return commentKeys;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(Account.Id author) {
+  public int getUpdateCount() {
+    return state.updateCount();
+  }
+
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
@@ -433,7 +453,7 @@
             draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getRobotComments() {
+  public ImmutableListMultimap<ObjectId, RobotComment> getRobotComments() {
     loadRobotComments();
     return robotCommentNotes.getComments();
   }
@@ -508,6 +528,19 @@
     ChangeNotesCache.Value v =
         args.cache.get().get(getProjectName(), getChangeId(), rev, handle::walk);
     state = v.state();
+
+    String stateServerId = state.serverId();
+    /**
+     * In earlier Gerrit versions serverId wasn't part of the change notes cache. That's why the
+     * earlier cached entries don't have the serverId attribute. That's fine because in earlier
+     * gerrit version serverId was already validated. Another approach to simplify the check would
+     * be to bump the cache version, but that would invalidate all persistent cache entries, what we
+     * rather try to avoid.
+     */
+    if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+      throw new InvalidServerIdException(args.serverId, stateServerId);
+    }
+
     state.copyColumnsTo(change);
     revisionNoteMap = v.revisionNoteMap();
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index e2af855..7fde297 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
@@ -61,7 +61,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(1)
+            .version(2)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
@@ -98,8 +98,8 @@
       public Key deserialize(byte[] in) {
         ChangeNotesKeyProto proto = Protos.parseUnchecked(ChangeNotesKeyProto.parser(), in);
         return Key.create(
-            new Project.NameKey(proto.getProject()),
-            new Change.Id(proto.getChangeId()),
+            Project.nameKey(proto.getProject()),
+            Change.id(proto.getChangeId()),
             ObjectIdConverter.create().fromByteString(proto.getId()));
       }
     }
@@ -112,8 +112,11 @@
     // Single pointer overhead.
     private static final int P = 8;
 
+    // Single int overhead.
+    private static final int I = 4;
+
     // Single IntKey overhead.
-    private static final int K = O + 4;
+    private static final int K = O + I;
 
     // Single Timestamp overhead.
     private static final int T = O + 8;
@@ -145,11 +148,8 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + ptr(state.columns().assignee(), K) // assignee
           + P // status
           + P
-          + set(state.pastAssignees(), K)
-          + P
           + set(state.hashtags(), str(10))
           + P
           + list(state.patchSets(), patchSet())
@@ -166,6 +166,8 @@
           + P
           + list(state.reviewerUpdates(), 4 * O + K + K + P)
           + P
+          + list(state.assigneeUpdates(), 4 * O + K + K)
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
@@ -173,11 +175,8 @@
           + map(state.publishedComments().asMap(), comment())
           + 1 // isPrivate
           + 1 // workInProgress
-          + 1; // reviewStarted
-    }
-
-    private static int ptr(Object o, int size) {
-      return o != null ? P + size : P;
+          + 1 // reviewStarted
+          + I; // updateCount
     }
 
     private static int str(String s) {
@@ -351,12 +350,7 @@
           "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
-              key.changeId(),
-              key.id(),
-              walkSupplier.get(),
-              args.changeNoteJson,
-              args.legacyChangeNoteRead,
-              args.metrics);
+              key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index d3b34b9..60162cd 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,7 +40,6 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
@@ -48,6 +47,7 @@
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
@@ -56,18 +56,17 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -101,26 +100,8 @@
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  // Sentinel RevId indicating a mutable field on a patch set was parsed, but
-  // the parser does not yet know its commit SHA-1.
-  private static final RevId PARTIAL_PATCH_SET = new RevId("INVALID PARTIAL PATCH SET");
-
-  @AutoValue
-  abstract static class ApprovalKey {
-    abstract PatchSet.Id psId();
-
-    abstract Account.Id accountId();
-
-    abstract String label();
-
-    private static ApprovalKey create(PatchSet.Id psId, Account.Id accountId, String label) {
-      return new AutoValue_ChangeNotesParser_ApprovalKey(psId, accountId, label);
-    }
-  }
-
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
-  private final LegacyChangeNoteRead legacyChangeNoteRead;
 
   private final NoteDbMetrics metrics;
   private final Change.Id id;
@@ -133,26 +114,26 @@
   private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
+  private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<RevId, Comment> comments;
-  private final Map<PatchSet.Id, PatchSet> patchSets;
+  private final ListMultimap<ObjectId, Comment> comments;
+  private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
-  private final Map<ApprovalKey, PatchSetApproval> approvals;
-  private final List<PatchSetApproval> bufferedApprovals;
+  private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
+  private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
 
   // Non-final private members filled in during the parsing process.
   private String branch;
   private Change.Status status;
   private String topic;
-  private Optional<Account.Id> assignee;
-  private List<Account.Id> pastAssignees;
   private Set<String> hashtags;
   private Timestamp createdOn;
   private Timestamp lastUpdatedOn;
   private Account.Id ownerId;
+  private String serverId;
   private String changeId;
   private String subject;
   private String originalSubject;
@@ -166,19 +147,18 @@
   private ReviewerSet pendingReviewers;
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
+  private int updateCount;
 
   ChangeNotesParser(
       Change.Id changeId,
       ObjectId tip,
       ChangeNotesRevWalk walk,
       ChangeNoteJson changeNoteJson,
-      LegacyChangeNoteRead legacyChangeNoteRead,
       NoteDbMetrics metrics) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.changeNoteJson = changeNoteJson;
-    this.legacyChangeNoteRead = legacyChangeNoteRead;
     this.metrics = metrics;
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
@@ -188,6 +168,7 @@
     pendingReviewersByEmail = ReviewerByEmailSet.empty();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
+    assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     comments = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -204,7 +185,7 @@
     walk.reset();
     walk.markStart(walk.parseCommit(tip));
 
-    try (Timer1.Context timer = metrics.parseLatency.start(CHANGES)) {
+    try (Timer1.Context<NoteDbTable> timer = metrics.parseLatency.start(CHANGES)) {
       ChangeNotesCommit commit;
       while ((commit = walk.next()) != null) {
         parse(commit);
@@ -232,25 +213,24 @@
     return revisionNoteMap;
   }
 
-  private ChangeNotesState buildState() {
+  private ChangeNotesState buildState() throws ConfigInvalidException {
     return ChangeNotesState.create(
         tip.copy(),
         id,
-        new Change.Key(changeId),
+        Change.key(changeId),
         createdOn,
         lastUpdatedOn,
         ownerId,
+        serverId,
         branch,
         buildCurrentPatchSetId(),
         subject,
         topic,
         originalSubject,
         submissionId,
-        assignee != null ? assignee.orElse(null) : null,
         status,
-        Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
         firstNonNull(hashtags, ImmutableSet.of()),
-        patchSets,
+        buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
         ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
@@ -258,20 +238,37 @@
         pendingReviewersByEmail,
         allPastReviewers,
         buildReviewerUpdates(),
+        assigneeUpdates,
         submitRecords,
         buildAllMessages(),
         comments,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
-        revertOf);
+        revertOf,
+        updateCount);
+  }
+
+  private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
+    Map<PatchSet.Id, PatchSet> result = Maps.newHashMapWithExpectedSize(patchSets.size());
+    for (Map.Entry<PatchSet.Id, PatchSet.Builder> e : patchSets.entrySet()) {
+      try {
+        PatchSet ps = e.getValue().build();
+        result.put(ps.id(), ps);
+      } catch (Exception ex) {
+        ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
+        cie.initCause(ex);
+        throw cie;
+      }
+    }
+    return result;
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
     // currentPatchSets are in parse order, i.e. newest first. Pick the first
     // patch set that was marked as current, excluding deleted patch sets.
     for (PatchSet.Id psId : currentPatchSets) {
-      if (patchSets.containsKey(psId)) {
+      if (patchSetCommitParsed(psId)) {
         return psId;
       }
     }
@@ -281,14 +278,14 @@
   private ListMultimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
     ListMultimap<PatchSet.Id, PatchSetApproval> result =
         MultimapBuilder.hashKeys().arrayListValues().build();
-    for (PatchSetApproval a : approvals.values()) {
-      if (!patchSets.containsKey(a.getPatchSetId())) {
+    for (PatchSetApproval.Builder a : approvals.values()) {
+      if (!patchSetCommitParsed(a.key().patchSetId())) {
         continue; // Patch set deleted or missing.
-      } else if (allPastReviewers.contains(a.getAccountId())
-          && !reviewers.containsRow(a.getAccountId())) {
+      } else if (allPastReviewers.contains(a.key().accountId())
+          && !reviewers.containsRow(a.key().accountId())) {
         continue; // Reviewer was explicitly removed.
       }
-      result.put(a.getPatchSetId(), a);
+      result.put(a.key().patchSetId(), a.build());
     }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
@@ -311,6 +308,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
+    updateCount++;
     Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
     createdOn = ts;
@@ -334,6 +332,10 @@
     Account.Id accountId = parseIdent(commit);
     if (accountId != null) {
       ownerId = accountId;
+      PersonIdent personIdent = commit.getAuthorIdent();
+      serverId = NoteDbUtil.extractHostPartFromPersonIdent(personIdent);
+    } else {
+      serverId = "UNKNOWN_SERVER_ID";
     }
     Account.Id realAccountId = parseRealAccountId(commit, accountId);
 
@@ -355,17 +357,29 @@
     }
 
     parseHashtags(commit);
-    parseAssignee(commit);
+    parseAssigneeUpdates(ts, commit);
 
     if (submissionId == null) {
       submissionId = parseSubmissionId(commit);
     }
 
+    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
+      lastUpdatedOn = ts;
+    }
+
+    if (deletedPatchSets.contains(psId)) {
+      // Do not update PS details as PS was deleted and this meta data is of no relevance.
+      return;
+    }
+
+    // Parse mutable patch set fields first so they can be recorded in the PendingPatchSetFields.
+    parseDescription(psId, commit);
+    parseGroups(psId, commit);
+
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
       parsePatchSet(psId, currRev, accountId, ts);
     }
-    parseGroups(psId, commit);
     parseCurrentPatchSet(psId, commit);
 
     if (submitRecords.isEmpty()) {
@@ -405,12 +419,6 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
-
-    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
-      lastUpdatedOn = ts;
-    }
-
-    parseDescription(psId, commit);
   }
 
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -437,7 +445,7 @@
       return effectiveAccountId;
     }
     PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
-    return legacyChangeNoteRead.parseIdent(ident, id);
+    return parseIdent(ident);
   }
 
   private String parseTopic(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -483,24 +491,23 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      patchSets.put(psId, ps);
-    } else if (!ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-      if (deletedPatchSets.contains(psId)) {
-        // Do not update PS details as PS was deleted and this meta data is of
-        // no relevance
-        return;
-      }
+    if (patchSetCommitParsed(psId)) {
+      ObjectId commitId = patchSets.get(psId).commitId().orElseThrow(IllegalStateException::new);
       throw new ConfigInvalidException(
           String.format(
               "Multiple revisions parsed for patch set %s: %s and %s",
-              psId.get(), patchSets.get(psId).getRevision(), rev.name()));
+              psId.get(), commitId.name(), rev.name()));
     }
-    ps.setRevision(new RevId(rev.name()));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(ts);
+    patchSets
+        .computeIfAbsent(psId, id -> PatchSet.builder())
+        .id(psId)
+        .commitId(rev)
+        .uploader(accountId)
+        .createdOn(ts);
+    // Fields not set here:
+    // * Groups, parsed earlier in parseGroups.
+    // * Description, parsed earlier in parseDescription.
+    // * Push certificate, parsed later in parseNotes.
   }
 
   private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -509,15 +516,11 @@
     if (groupsStr == null) {
       return;
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      ps.setRevision(PARTIAL_PATCH_SET);
-      patchSets.put(psId, ps);
-    } else if (!ps.getGroups().isEmpty()) {
-      return;
+    checkPatchSetCommitNotParsed(psId, FOOTER_GROUPS);
+    PatchSet.Builder pending = patchSets.computeIfAbsent(psId, id -> PatchSet.builder());
+    if (pending.groups().isEmpty()) {
+      pending.groups(PatchSet.splitGroups(groupsStr));
     }
-    ps.setGroups(PatchSet.splitGroups(groupsStr));
   }
 
   private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -560,10 +563,8 @@
     }
   }
 
-  private void parseAssignee(ChangeNotesCommit commit) throws ConfigInvalidException {
-    if (pastAssignees == null) {
-      pastAssignees = Lists.newArrayList();
-    }
+  private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
+      throws ConfigInvalidException {
     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
     if (assigneeValue != null) {
       Optional<Account.Id> parsedAssignee;
@@ -572,14 +573,9 @@
         parsedAssignee = Optional.empty();
       } else {
         PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
-        parsedAssignee = Optional.ofNullable(legacyChangeNoteRead.parseIdent(ident, id));
+        parsedAssignee = Optional.ofNullable(parseIdent(ident));
       }
-      if (assignee == null) {
-        assignee = parsedAssignee;
-      }
-      if (parsedAssignee.isPresent()) {
-        pastAssignees.add(parsedAssignee.get());
-      }
+      assigneeUpdates.add(AssigneeStatusUpdate.create(ts, ownerId, parsedAssignee));
     }
   }
 
@@ -612,9 +608,9 @@
     // exception is the legacy SUBM approval, which is never considered post-submit, but might end
     // up sorted after the submit during rebuilding.
     if (status == Change.Status.MERGED) {
-      for (PatchSetApproval psa : bufferedApprovals) {
-        if (!psa.isLegacySubmit()) {
-          psa.setPostSubmit(true);
+      for (PatchSetApproval.Builder psa : bufferedApprovals) {
+        if (!psa.key().isLegacySubmit()) {
+          psa.postSubmit(true);
         }
       }
     }
@@ -630,7 +626,7 @@
     if (psId == null) {
       throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     }
-    return new PatchSet.Id(id, psId);
+    return PatchSet.id(id, psId);
   }
 
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -658,16 +654,14 @@
     List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
     if (descLines.isEmpty()) {
       return;
-    } else if (descLines.size() == 1) {
+    }
+
+    checkPatchSetCommitNotParsed(psId, FOOTER_PATCH_SET_DESCRIPTION);
+    if (descLines.size() == 1) {
       String desc = descLines.get(0).trim();
-      PatchSet ps = patchSets.get(psId);
-      if (ps == null) {
-        ps = new PatchSet(psId);
-        ps.setRevision(PARTIAL_PATCH_SET);
-        patchSets.put(psId, ps);
-      }
-      if (ps.getDescription() == null) {
-        ps.setDescription(desc);
+      PatchSet.Builder pending = patchSets.computeIfAbsent(psId, p -> PatchSet.builder());
+      if (!pending.description().isPresent()) {
+        pending.description(Optional.of(desc));
       }
     } else {
       throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
@@ -686,8 +680,7 @@
     }
 
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), commit.name()), accountId, ts, psId);
+        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
     changeMessage.setMessage(changeMsgString.get());
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
@@ -713,24 +706,24 @@
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     revisionNoteMap =
         RevisionNoteMap.parse(
-            changeNoteJson,
-            legacyChangeNoteRead,
-            id,
-            reader,
-            NoteMap.read(reader, tipCommit),
-            PatchLineComment.Status.PUBLISHED);
-    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
+            changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.PUBLISHED);
+    Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
+    for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
       for (Comment c : e.getValue().getEntities()) {
         comments.put(e.getKey(), c);
       }
     }
 
-    for (PatchSet ps : patchSets.values()) {
-      ChangeRevisionNote rn = rns.get(ps.getRevision());
+    for (PatchSet.Builder b : patchSets.values()) {
+      ObjectId commitId =
+          b.commitId()
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException("never parsed commit ID for patch set " + b.id()));
+      ChangeRevisionNote rn = rns.get(commitId);
       if (rn != null && rn.getPushCert() != null) {
-        ps.setPushCertificate(rn.getPushCert());
+        b.pushCertificate(Optional.of(rn.getPushCert()));
       }
     }
   }
@@ -741,7 +734,7 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSetApproval psa;
+    PatchSetApproval.Builder psa;
     if (line.startsWith("-")) {
       psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
@@ -750,7 +743,7 @@
     bufferedApprovals.add(psa);
   }
 
-  private PatchSetApproval parseAddApproval(
+  private PatchSetApproval.Builder parseAddApproval(
       PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
     // There are potentially 3 accounts involved here:
@@ -772,7 +765,7 @@
       labelVoteStr = line.substring(0, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = legacyChangeNoteRead.parseIdent(ident, id);
+      effectiveAccountId = parseIdent(ident);
     } else {
       labelVoteStr = line;
       effectiveAccountId = committerId;
@@ -787,23 +780,20 @@
       throw pe;
     }
 
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(l.label())),
-            l.value(),
-            ts);
-    psa.setTag(tag);
+    PatchSetApproval.Builder psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .value(l.value())
+            .granted(ts)
+            .tag(Optional.ofNullable(tag));
     if (!Objects.equals(realAccountId, committerId)) {
-      psa.setRealAccountId(realAccountId);
+      psa.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, l.label());
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, psa);
-    }
+    approvals.putIfAbsent(psa.key(), psa);
     return psa;
   }
 
-  private PatchSetApproval parseRemoveApproval(
+  private PatchSetApproval.Builder parseRemoveApproval(
       PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
     // See comments in parseAddApproval about the various users involved.
@@ -814,7 +804,7 @@
       label = line.substring(1, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = legacyChangeNoteRead.parseIdent(ident, id);
+      effectiveAccountId = parseIdent(ident);
     } else {
       label = line.substring(1);
       effectiveAccountId = committerId;
@@ -830,16 +820,15 @@
 
     // Store an actual 0-vote approval in the map for a removed approval, because ApprovalCopier
     // needs an actual approval in order to block copying an earlier approval over a later delete.
-    PatchSetApproval remove =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(label)), (short) 0, ts);
+    PatchSetApproval.Builder remove =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(label)))
+            .value(0)
+            .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
-      remove.setRealAccountId(realAccountId);
+      remove.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, remove);
-    }
+    approvals.putIfAbsent(remove.key(), remove);
     return remove;
   }
 
@@ -874,7 +863,7 @@
           label.label = line.substring(c + 2, c2);
           PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
           checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
-          label.appliedBy = legacyChangeNoteRead.parseIdent(ident, id);
+          label.appliedBy = parseIdent(ident);
         } else {
           label.label = line.substring(c + 2);
         }
@@ -890,7 +879,7 @@
     if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
       return null;
     }
-    return legacyChangeNoteRead.parseIdent(commit.getAuthorIdent(), id);
+    return parseIdent(commit.getAuthorIdent());
   }
 
   private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
@@ -899,7 +888,7 @@
     if (ident == null) {
       throw invalidFooter(state.getFooterKey(), line);
     }
-    Account.Id accountId = legacyChangeNoteRead.parseIdent(ident, id);
+    Account.Id accountId = parseIdent(ident);
     reviewerUpdates.add(ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
     if (!reviewers.containsRow(accountId)) {
       reviewers.put(accountId, state, ts);
@@ -976,7 +965,7 @@
     if (revertOf == null) {
       throw invalidFooter(FOOTER_REVERT_OF, footer);
     }
-    return new Change.Id(revertOf);
+    return Change.id(revertOf);
   }
 
   private void pruneReviewers() {
@@ -1003,13 +992,8 @@
 
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(comparing(PatchSet.Id::get));
-    for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
-      PatchSet ps = it.next();
-      if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-        missing.add(ps.getId());
-        it.remove();
-      }
-    }
+    patchSets.keySet().stream().filter(p -> !patchSetCommitParsed(p)).forEach(p -> missing.add(p));
+
     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
       switch (e.getValue()) {
         case PUBLISHED:
@@ -1030,10 +1014,10 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId), missing);
+            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            approvals.values(), PatchSetApproval::getPatchSetId, missing);
+            approvals.values(), psa -> psa.key().patchSetId(), missing);
 
     if (!missing.isEmpty()) {
       logger.atWarning().log(
@@ -1046,7 +1030,7 @@
     int pruned = 0;
     for (Iterator<T> it = ents.iterator(); it.hasNext(); ) {
       PatchSet.Id psId = psIdFunc.apply(it.next());
-      if (!patchSets.containsKey(psId)) {
+      if (!patchSetCommitParsed(psId)) {
         pruned++;
         missing.add(psId);
         it.remove();
@@ -1089,7 +1073,27 @@
     }
   }
 
+  private void checkPatchSetCommitNotParsed(PatchSet.Id psId, FooterKey footer)
+      throws ConfigInvalidException {
+    if (patchSetCommitParsed(psId)) {
+      throw parseException(
+          "%s field found for patch set %s before patch set was originally defined",
+          footer.getName(), psId.get());
+    }
+  }
+
+  private boolean patchSetCommitParsed(PatchSet.Id psId) {
+    PatchSet.Builder pending = patchSets.get(psId);
+    return pending != null && pending.commitId().isPresent();
+  }
+
   private ConfigInvalidException parseException(String fmt, Object... args) {
     return ChangeNotes.parseException(id, fmt, args);
   }
+
+  private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
+    return NoteDbUtil.parseIdent(ident)
+        .orElseThrow(
+            () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index fd260e7..896cca3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
@@ -35,26 +34,27 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
-import com.google.gerrit.reviewdb.converter.ProtoConverter;
+import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -68,6 +68,7 @@
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -97,15 +98,14 @@
       Timestamp createdOn,
       Timestamp lastUpdatedOn,
       Account.Id owner,
+      String serverId,
       String branch,
       @Nullable PatchSet.Id currentPatchSetId,
       String subject,
       @Nullable String topic,
       @Nullable String originalSubject,
       @Nullable String submissionId,
-      @Nullable Account.Id assignee,
       @Nullable Change.Status status,
-      Set<Account.Id> pastAssignees,
       Set<String> hashtags,
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
@@ -115,13 +115,15 @@
       ReviewerByEmailSet pendingReviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
+      List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<RevId, Comment> publishedComments,
+      ListMultimap<ObjectId, Comment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
-      @Nullable Change.Id revertOf) {
+      @Nullable Change.Id revertOf,
+      int updateCount) {
     requireNonNull(
         metaId,
         () ->
@@ -146,14 +148,13 @@
                 .topic(topic)
                 .originalSubject(originalSubject)
                 .submissionId(submissionId)
-                .assignee(assignee)
                 .isPrivate(isPrivate)
                 .workInProgress(workInProgress)
                 .reviewStarted(reviewStarted)
                 .revertOf(revertOf)
                 .build())
-        .pastAssignees(pastAssignees)
         .hashtags(hashtags)
+        .serverId(serverId)
         .patchSets(patchSets.entrySet())
         .approvals(approvals.entries())
         .reviewers(reviewers)
@@ -162,9 +163,11 @@
         .pendingReviewersByEmail(pendingReviewersByEmail)
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
+        .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .updateCount(updateCount)
         .build();
   }
 
@@ -208,9 +211,6 @@
     @Nullable
     abstract String submissionId();
 
-    @Nullable
-    abstract Account.Id assignee();
-
     abstract boolean isPrivate();
 
     abstract boolean workInProgress();
@@ -244,8 +244,6 @@
 
       abstract Builder submissionId(@Nullable String submissionId);
 
-      abstract Builder assignee(@Nullable Account.Id assignee);
-
       abstract Builder status(@Nullable Change.Status status);
 
       abstract Builder isPrivate(boolean isPrivate);
@@ -271,10 +269,11 @@
   abstract ChangeColumns columns();
 
   // Other related to this Change.
-  abstract ImmutableSet<Account.Id> pastAssignees();
-
   abstract ImmutableSet<String> hashtags();
 
+  @Nullable
+  abstract String serverId();
+
   abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSet>> patchSets();
 
   abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals();
@@ -291,11 +290,15 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
+  abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
+
   abstract ImmutableList<SubmitRecord> submitRecords();
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<RevId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
+
+  abstract int updateCount();
 
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
@@ -304,7 +307,7 @@
             c.changeKey(),
             changeId(),
             c.owner(),
-            new Branch.NameKey(project, c.branch()),
+            BranchNameKey.create(project, c.branch()),
             c.createdOn());
     copyNonConstructorColumnsTo(change);
     return change;
@@ -318,7 +321,7 @@
         this);
     change.setKey(c.changeKey());
     change.setOwner(c.owner());
-    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
+    change.setDest(BranchNameKey.create(change.getProject(), c.branch()));
     change.setCreatedOn(c.createdOn());
     copyNonConstructorColumnsTo(change);
   }
@@ -331,7 +334,9 @@
     change.setTopic(Strings.emptyToNull(c.topic()));
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
-    change.setAssignee(c.assignee());
+    if (!assigneeUpdates().isEmpty()) {
+      change.setAssignee(assigneeUpdates().get(0).currentAssignee().orElse(null));
+    }
     change.setPrivate(c.isPrivate());
     change.setWorkInProgress(c.workInProgress());
     change.setReviewStarted(c.reviewStarted());
@@ -351,7 +356,6 @@
     static Builder empty(Change.Id changeId) {
       return new AutoValue_ChangeNotesState.Builder()
           .changeId(changeId)
-          .pastAssignees(ImmutableSet.of())
           .hashtags(ImmutableSet.of())
           .patchSets(ImmutableList.of())
           .approvals(ImmutableList.of())
@@ -361,9 +365,11 @@
           .pendingReviewersByEmail(ReviewerByEmailSet.empty())
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
+          .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
-          .publishedComments(ImmutableListMultimap.of());
+          .publishedComments(ImmutableListMultimap.of())
+          .updateCount(0);
     }
 
     abstract Builder metaId(ObjectId metaId);
@@ -372,7 +378,7 @@
 
     abstract Builder columns(ChangeColumns columns);
 
-    abstract Builder pastAssignees(Set<Account.Id> pastAssignees);
+    abstract Builder serverId(String serverId);
 
     abstract Builder hashtags(Iterable<String> hashtags);
 
@@ -392,11 +398,15 @@
 
     abstract Builder reviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates);
 
+    abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
+
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<RevId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
+
+    abstract Builder updateCount(int updateCount);
 
     abstract ChangeNotesState build();
   }
@@ -421,7 +431,10 @@
           .setChangeId(object.changeId().get())
           .setColumns(toChangeColumnsProto(object.columns()));
 
-      object.pastAssignees().forEach(a -> b.addPastAssignee(a.get()));
+      if (object.serverId() != null) {
+        b.setServerId(object.serverId());
+        b.setHasServerId(true);
+      }
       object.hashtags().forEach(b::addHashtag);
       object
           .patchSets()
@@ -452,6 +465,7 @@
 
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
+      object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
@@ -459,6 +473,7 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      b.setUpdateCount(object.updateCount());
 
       return Protos.toByteArray(b.build());
     }
@@ -490,9 +505,6 @@
       if (cols.submissionId() != null) {
         b.setSubmissionId(cols.submissionId()).setHasSubmissionId(true);
       }
-      if (cols.assignee() != null) {
-        b.setAssignee(cols.assignee().get()).setHasAssignee(true);
-      }
       if (cols.status() != null) {
         b.setStatus(STATUS_CONVERTER.reverse().convert(cols.status())).setHasStatus(true);
       }
@@ -532,40 +544,47 @@
           .build();
     }
 
+    private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
+      AssigneeStatusUpdateProto.Builder builder =
+          AssigneeStatusUpdateProto.newBuilder()
+              .setDate(u.date().getTime())
+              .setUpdatedBy(u.updatedBy().get())
+              .setHasCurrentAssignee(u.currentAssignee().isPresent());
+
+      u.currentAssignee().ifPresent(assignee -> builder.setCurrentAssignee(assignee.get()));
+      return builder.build();
+    }
+
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
-      Change.Id changeId = new Change.Id(proto.getChangeId());
+      Change.Id changeId = Change.id(proto.getChangeId());
 
       ChangeNotesState.Builder b =
           builder()
               .metaId(ObjectIdConverter.create().fromByteString(proto.getMetaId()))
               .changeId(changeId)
               .columns(toChangeColumns(changeId, proto.getColumns()))
-              .pastAssignees(
-                  proto.getPastAssigneeList().stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableSet()))
+              .serverId(proto.getHasServerId() ? proto.getServerId() : null)
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
                       .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
-                      .map(ps -> Maps.immutableEntry(ps.getId(), ps))
+                      .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
                       .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
-                      .map(a -> Maps.immutableEntry(a.getPatchSetId(), a))
+                      .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
               .reviewersByEmail(toReviewerByEmailSet(proto.getReviewerByEmailList()))
               .pendingReviewers(toReviewerSet(proto.getPendingReviewerList()))
               .pendingReviewersByEmail(toReviewerByEmailSet(proto.getPendingReviewerByEmailList()))
               .allPastReviewers(
-                  proto.getPastReviewerList().stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableList()))
+                  proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
+              .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
                       .map(r -> GSON.fromJson(r, StoredSubmitRecord.class).toSubmitRecord())
@@ -577,7 +596,8 @@
               .publishedComments(
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(c -> new RevId(c.revId), c -> c)));
+                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+              .updateCount(proto.getUpdateCount());
       return b.build();
     }
 
@@ -590,13 +610,13 @@
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
-              .changeKey(new Change.Key(proto.getChangeKey()))
+              .changeKey(Change.key(proto.getChangeKey()))
               .createdOn(new Timestamp(proto.getCreatedOn()))
               .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()))
-              .owner(new Account.Id(proto.getOwner()))
+              .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
-        b.currentPatchSetId(new PatchSet.Id(changeId, proto.getCurrentPatchSetId()));
+        b.currentPatchSetId(PatchSet.id(changeId, proto.getCurrentPatchSetId()));
       }
       b.subject(proto.getSubject());
       if (proto.getHasTopic()) {
@@ -608,9 +628,6 @@
       if (proto.getHasSubmissionId()) {
         b.submissionId(proto.getSubmissionId());
       }
-      if (proto.getHasAssignee()) {
-        b.assignee(new Account.Id(proto.getAssignee()));
-      }
       if (proto.getHasStatus()) {
         b.status(STATUS_CONVERTER.convert(proto.getStatus()));
       }
@@ -618,7 +635,7 @@
           .workInProgress(proto.getWorkInProgress())
           .reviewStarted(proto.getReviewStarted());
       if (proto.getHasRevertOf()) {
-        b.revertOf(new Change.Id(proto.getRevertOf()));
+        b.revertOf(Change.id(proto.getRevertOf()));
       }
       return b.build();
     }
@@ -629,7 +646,7 @@
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
-            new Account.Id(e.getAccountId()),
+            Account.id(e.getAccountId()),
             new Timestamp(e.getTimestamp()));
       }
       return ReviewerSet.fromTable(b.build());
@@ -655,11 +672,26 @@
         b.add(
             ReviewerStatusUpdate.create(
                 new Timestamp(proto.getDate()),
-                new Account.Id(proto.getUpdatedBy()),
-                new Account.Id(proto.getReviewer()),
+                Account.id(proto.getUpdatedBy()),
+                Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
       }
       return b.build();
     }
+
+    private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
+        List<AssigneeStatusUpdateProto> protos) {
+      ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
+      for (AssigneeStatusUpdateProto proto : protos) {
+        b.add(
+            AssigneeStatusUpdate.create(
+                new Timestamp(proto.getDate()),
+                Account.id(proto.getUpdatedBy()),
+                proto.getHasCurrentAssignee()
+                    ? Optional.of(Account.id(proto.getCurrentAssignee()))
+                    : Optional.empty()));
+      }
+      return b.build();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 66dd5e8..b6443f1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,10 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.entities.Comment;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -30,30 +27,16 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.RawParseUtils;
 
 class ChangeRevisionNote extends RevisionNote<Comment> {
-  private static final byte[] CERT_HEADER = "certificate version ".getBytes(UTF_8);
-  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
-  private static final byte[] END_SIGNATURE = "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
-
   private final ChangeNoteJson noteJson;
-  private final LegacyChangeNoteRead legacyChangeNoteRead;
-  private final Change.Id changeId;
-  private final PatchLineComment.Status status;
+  private final Comment.Status status;
   private String pushCert;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson,
-      LegacyChangeNoteRead legacyChangeNoteRead,
-      Change.Id changeId,
-      ObjectReader reader,
-      ObjectId noteId,
-      PatchLineComment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
     super(reader, noteId);
-    this.legacyChangeNoteRead = legacyChangeNoteRead;
     this.noteJson = noteJson;
-    this.changeId = changeId;
     this.status = status;
   }
 
@@ -67,29 +50,13 @@
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    if (isJson(raw, p.value)) {
-      RevisionNoteData data = parseJson(noteJson, raw, p.value);
-      if (status == PatchLineComment.Status.PUBLISHED) {
-        pushCert = data.pushCert;
-      } else {
-        pushCert = null;
-      }
-      return data.comments;
-    }
-
-    if (status == PatchLineComment.Status.PUBLISHED) {
-      pushCert = parsePushCert(changeId, raw, p);
-      trimLeadingEmptyLines(raw, p);
+    RevisionNoteData data = parseJson(noteJson, raw, p.value);
+    if (status == Comment.Status.PUBLISHED) {
+      pushCert = data.pushCert;
     } else {
       pushCert = null;
     }
-    List<Comment> comments = legacyChangeNoteRead.parseNote(raw, p, changeId);
-    comments.forEach(c -> c.legacyFormat = true);
-    return comments;
-  }
-
-  static boolean isJson(byte[] raw, int offset) {
-    return raw[offset] == '{' || raw[offset] == '[';
+    return data.comments;
   }
 
   private RevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
@@ -99,18 +66,4 @@
       return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
     }
   }
-
-  private static String parsePushCert(Change.Id changeId, byte[] bytes, MutableInteger p)
-      throws ConfigInvalidException {
-    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
-      return null;
-    }
-    int end = Bytes.indexOf(bytes, END_SIGNATURE);
-    if (end < 0) {
-      throw ChangeNotes.parseException(changeId, "invalid push certificate in note");
-    }
-    int start = p.value;
-    p.value = end + END_SIGNATURE.length;
-    return new String(bytes, start, p.value, UTF_8);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 0cde363..b2f85fc 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
@@ -39,7 +39,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
-import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -50,21 +50,18 @@
 import com.google.common.collect.Table;
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.client.IntKey;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -173,7 +170,7 @@
 
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
+    return TreeBasedTable.create(nameComparator, naturalOrder());
   }
 
   @AssistedInject
@@ -250,11 +247,6 @@
     checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
   }
 
-  @Deprecated // Only until we improve ChangeRebuilder to call merge().
-  public void setSubmissionId(String submissionId) {
-    this.submissionId = submissionId;
-  }
-
   public void setSubjectForCommit(String commitSubject) {
     this.commitSubject = commitSubject;
   }
@@ -280,18 +272,14 @@
     this.psDescription = psDescription;
   }
 
-  public void putComment(PatchLineComment.Status status, Comment c) {
+  public void putComment(Comment.Status status, Comment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (status == PatchLineComment.Status.DRAFT) {
+    if (status == Comment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
-      // Always delete the corresponding comment from drafts. Published comments
-      // are immutable, meaning in normal operation we only hit this path when
-      // publishing a comment. It's exactly in that case that we have to delete
-      // the draft.
-      draftUpdate.deleteComment(c);
+      draftUpdate.markCommentPublished(c);
     }
   }
 
@@ -421,7 +409,7 @@
   }
 
   public void setRevertOf(int revertOf) {
-    int ownId = getChange().getId().get();
+    int ownId = getId().get();
     checkArgument(ownId != revertOf, "A change cannot revert itself");
     this.revertOf = revertOf;
     rootOnly = true;
@@ -438,18 +426,18 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (Comment c : comments) {
       c.tag = tag;
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
     if (pushCert != null) {
       checkState(commit != null);
-      cache.get(new RevId(commit)).setPushCertificate(pushCert);
+      cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
     }
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     checkComments(rnm.revisionNotes, builders);
 
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
       ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson()));
-      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
+      rnm.noteMap.set(e.getKey(), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
@@ -473,16 +461,12 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(),
-        noteUtil.getLegacyChangeNoteRead(),
-        getId(),
-        rw.getObjectReader(),
-        noteMap,
-        PatchLineComment.Status.PUBLISHED);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.PUBLISHED);
   }
 
   private void checkComments(
-      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate) {
+      Map<ObjectId, ChangeRevisionNote> existingNotes,
+      Map<ObjectId, RevisionNoteBuilder> toUpdate) {
     // Prohibit various kinds of illegal operations on comments.
     Set<Comment.Key> existing = new HashSet<>();
     for (ChangeRevisionNote rn : existingNotes.values()) {
@@ -504,7 +488,7 @@
           // separate commit. But note that we don't care much about the commit
           // graph of the draft ref, particularly because the ref is completely
           // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.revId, c.key);
+          draftUpdate.deleteComment(c.getCommitId(), c.key);
         }
       }
     }
@@ -524,6 +508,12 @@
   }
 
   @Override
+  protected boolean bypassMaxUpdates() {
+    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return status != null && status.isClosed();
+  }
+
+  @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException {
     checkState(
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
index 8fb28e1..b555fdb 100644
--- a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.util.Optional;
@@ -46,7 +46,7 @@
 
   DeleteChangeMessageRewriter(Change.Id changeId, String targetMessageId, String newChangeMessage) {
     this.changeId = changeId;
-    this.targetMessageId = checkNotNull(targetMessageId);
+    this.targetMessageId = requireNonNull(targetMessageId);
     this.newChangeMessage = newChangeMessage;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index c100550..9c8b369 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -15,16 +15,15 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
+import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -96,13 +95,13 @@
     ObjectReader reader = revWalk.getObjectReader();
     RevCommit newTipCommit = revWalk.next(); // The first commit will not be rewritten.
     Map<String, Comment> parentComments =
-        getPublishedComments(noteUtil, changeId, reader, NoteMap.read(reader, newTipCommit));
+        getPublishedComments(noteUtil, reader, NoteMap.read(reader, newTipCommit));
 
     boolean rewrite = false;
     RevCommit originalCommit;
     while ((originalCommit = revWalk.next()) != null) {
       NoteMap noteMap = NoteMap.read(reader, originalCommit);
-      Map<String, Comment> currComments = getPublishedComments(noteUtil, changeId, reader, noteMap);
+      Map<String, Comment> currComments = getPublishedComments(noteUtil, reader, noteMap);
 
       if (!rewrite && currComments.containsKey(uuid)) {
         rewrite = true;
@@ -132,28 +131,18 @@
    */
   @VisibleForTesting
   public static Map<String, Comment> getPublishedComments(
-      ChangeNoteJson changeNoteJson,
-      LegacyChangeNoteRead legacyChangeNoteRead,
-      Change.Id changeId,
-      ObjectReader reader,
-      NoteMap noteMap)
+      ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
-    return RevisionNoteMap.parse(
-            changeNoteJson, legacyChangeNoteRead, changeId, reader, noteMap, PUBLISHED)
-        .revisionNotes.values().stream()
+    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, Status.PUBLISHED).revisionNotes
+        .values().stream()
         .flatMap(n -> n.getEntities().stream())
         .collect(toMap(c -> c.key.uuid, Function.identity()));
   }
 
   public static Map<String, Comment> getPublishedComments(
-      ChangeNoteUtil noteUtil, Change.Id changeId, ObjectReader reader, NoteMap noteMap)
+      ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
-    return getPublishedComments(
-        noteUtil.getChangeNoteJson(),
-        noteUtil.getLegacyChangeNoteRead(),
-        changeId,
-        reader,
-        noteMap);
+    return getPublishedComments(noteUtil.getChangeNoteJson(), reader, noteMap);
   }
   /**
    * Gets the comments put in by the current commit. The message of the target comment will be
@@ -216,24 +205,22 @@
     RevisionNoteMap<ChangeRevisionNote> revNotesMap =
         RevisionNoteMap.parse(
             noteUtil.getChangeNoteJson(),
-            noteUtil.getLegacyChangeNoteRead(),
-            changeId,
             reader,
             NoteMap.read(reader, parentCommit),
-            PUBLISHED);
+            Status.PUBLISHED);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
     for (Comment c : putInComments) {
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
     for (Comment c : deletedComments) {
-      cache.get(new RevId(c.revId)).deleteComment(c.key);
+      cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
-      ObjectId objectId = ObjectId.fromString(entry.getKey().get());
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> entry : builders.entrySet()) {
+      ObjectId objectId = entry.getKey();
       byte[] data = entry.getValue().build(noteUtil.getChangeNoteJson());
       if (data.length == 0) {
         revNotesMap.noteMap.remove(objectId);
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 495ac65..128e185 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -18,8 +18,8 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 213613e..3966396 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.entities.RefNames.refsDraftComments;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -24,12 +24,10 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Project;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -52,7 +50,7 @@
   private final Account.Id author;
   private final Ref ref;
 
-  private ImmutableListMultimap<RevId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, Comment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
@@ -82,7 +80,7 @@
     return author;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return comments;
   }
 
@@ -122,16 +120,11 @@
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
         RevisionNoteMap.parse(
-            args.changeNoteJson,
-            args.legacyChangeNoteRead,
-            getChangeId(),
-            reader,
-            NoteMap.read(reader, tipCommit),
-            PatchLineComment.Status.DRAFT);
-    ListMultimap<RevId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+            args.changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.DRAFT);
+    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (Comment c : rn.getEntities()) {
-        cs.put(new RevId(c.revId), c);
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
index 6305a54..c0af37f 100644
--- a/java/com/google/gerrit/server/notedb/IntBlob.java
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -22,9 +22,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/notedb/InvalidServerIdException.java b/java/com/google/gerrit/server/notedb/InvalidServerIdException.java
new file mode 100644
index 0000000..f79e07c
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/InvalidServerIdException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+public class InvalidServerIdException extends IllegalStateException {
+  private static final long serialVersionUID = 5302751510361680907L;
+
+  public InvalidServerIdException(String expectedServerId, String actualServerId) {
+    super(
+        String.format(
+            "invalid server id, expected %s: actual: %s", expectedServerId, actualServerId));
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
deleted file mode 100644
index 916cc16..0000000
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
+++ /dev/null
@@ -1,401 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.inject.Inject;
-import java.sql.Timestamp;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.util.GitDateParser;
-import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.QuotedString;
-import org.eclipse.jgit.util.RawParseUtils;
-
-public class LegacyChangeNoteRead {
-  private final String serverId;
-
-  @Inject
-  public LegacyChangeNoteRead(@GerritServerId String serverId) {
-    this.serverId = serverId;
-  }
-
-  public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
-      throws ConfigInvalidException {
-    return NoteDbUtil.parseIdent(ident, serverId)
-        .orElseThrow(
-            () ->
-                parseException(
-                    changeId,
-                    "invalid identity, expected <id>@%s: %s",
-                    serverId,
-                    ident.getEmailAddress()));
-  }
-
-  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
-    int m = RawParseUtils.match(note, p.value, expected);
-    return m == p.value + expected.length;
-  }
-
-  public List<Comment> parseNote(byte[] note, MutableInteger p, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (p.value >= note.length) {
-      return ImmutableList.of();
-    }
-    Set<Comment.Key> seen = new HashSet<>();
-    List<Comment> result = new ArrayList<>();
-    int sizeOfNote = note.length;
-    byte[] psb = ChangeNoteUtil.PATCH_SET.getBytes(UTF_8);
-    byte[] bpsb = ChangeNoteUtil.BASE_PATCH_SET.getBytes(UTF_8);
-    byte[] bpn = ChangeNoteUtil.PARENT_NUMBER.getBytes(UTF_8);
-
-    RevId revId = new RevId(parseStringField(note, p, changeId, ChangeNoteUtil.REVISION));
-    String fileName = null;
-    PatchSet.Id psId = null;
-    boolean isForBase = false;
-    Integer parentNumber = null;
-
-    while (p.value < sizeOfNote) {
-      boolean matchPs = match(note, p, psb);
-      boolean matchBase = match(note, p, bpsb);
-      if (matchPs) {
-        fileName = null;
-        psId = parsePsId(note, p, changeId, ChangeNoteUtil.PATCH_SET);
-        isForBase = false;
-      } else if (matchBase) {
-        fileName = null;
-        psId = parsePsId(note, p, changeId, ChangeNoteUtil.BASE_PATCH_SET);
-        isForBase = true;
-        if (match(note, p, bpn)) {
-          parentNumber = parseParentNumber(note, p, changeId);
-        }
-      } else if (psId == null) {
-        throw parseException(
-            changeId,
-            "missing %s or %s header",
-            ChangeNoteUtil.PATCH_SET,
-            ChangeNoteUtil.BASE_PATCH_SET);
-      }
-
-      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
-      fileName = c.key.filename;
-      if (!seen.add(c.key)) {
-        throw parseException(changeId, "multiple comments for %s in note", c.key);
-      }
-      result.add(c);
-    }
-    return result;
-  }
-
-  private Comment parseComment(
-      byte[] note,
-      MutableInteger curr,
-      String currentFileName,
-      PatchSet.Id psId,
-      RevId revId,
-      boolean isForBase,
-      Integer parentNumber)
-      throws ConfigInvalidException {
-    Change.Id changeId = psId.getParentKey();
-
-    // Check if there is a new file.
-    boolean newFile =
-        (RawParseUtils.match(note, curr.value, ChangeNoteUtil.FILE.getBytes(UTF_8))) != -1;
-    if (newFile) {
-      // If so, parse the new file name.
-      currentFileName = parseFilename(note, curr, changeId);
-    } else if (currentFileName == null) {
-      throw parseException(changeId, "could not parse %s", ChangeNoteUtil.FILE);
-    }
-
-    CommentRange range = parseCommentRange(note, curr);
-    if (range == null) {
-      throw parseException(changeId, "could not parse %s", ChangeNoteUtil.COMMENT_RANGE);
-    }
-
-    Timestamp commentTime = parseTimestamp(note, curr, changeId);
-    Account.Id aId = parseAuthor(note, curr, changeId, ChangeNoteUtil.AUTHOR);
-    boolean hasRealAuthor =
-        (RawParseUtils.match(note, curr.value, ChangeNoteUtil.REAL_AUTHOR.getBytes(UTF_8))) != -1;
-    Account.Id raId = null;
-    if (hasRealAuthor) {
-      raId = parseAuthor(note, curr, changeId, ChangeNoteUtil.REAL_AUTHOR);
-    }
-
-    boolean hasParent =
-        (RawParseUtils.match(note, curr.value, ChangeNoteUtil.PARENT.getBytes(UTF_8))) != -1;
-    String parentUUID = null;
-    boolean unresolved = false;
-    if (hasParent) {
-      parentUUID = parseStringField(note, curr, changeId, ChangeNoteUtil.PARENT);
-    }
-    boolean hasUnresolved =
-        (RawParseUtils.match(note, curr.value, ChangeNoteUtil.UNRESOLVED.getBytes(UTF_8))) != -1;
-    if (hasUnresolved) {
-      unresolved = parseBooleanField(note, curr, changeId, ChangeNoteUtil.UNRESOLVED);
-    }
-
-    String uuid = parseStringField(note, curr, changeId, ChangeNoteUtil.UUID);
-
-    boolean hasTag =
-        (RawParseUtils.match(note, curr.value, ChangeNoteUtil.TAG.getBytes(UTF_8))) != -1;
-    String tag = null;
-    if (hasTag) {
-      tag = parseStringField(note, curr, changeId, ChangeNoteUtil.TAG);
-    }
-
-    int commentLength = parseCommentLength(note, curr, changeId);
-
-    String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength);
-    checkResult(message, "message contents", changeId);
-
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, currentFileName, psId.get()),
-            aId,
-            commentTime,
-            isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1,
-            message,
-            serverId,
-            unresolved);
-    c.lineNbr = range.getEndLine();
-    c.parentUuid = parentUUID;
-    c.tag = tag;
-    c.setRevId(revId);
-    if (raId != null) {
-      c.setRealAuthor(raId);
-    }
-
-    if (range.getStartCharacter() != -1) {
-      c.setRange(range);
-    }
-
-    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return c;
-  }
-
-  private static String parseStringField(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    curr.value = endOfLine;
-    return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
-  }
-
-  /**
-   * @return a comment range. If the comment range line in the note only has one number, we return a
-   *     CommentRange with that one number as the end line and the other fields as -1. If the
-   *     comment range line in the note contains a whole comment range, then we return a
-   *     CommentRange with all fields set. If the line is not correctly formatted, return null.
-   */
-  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
-    CommentRange range = new CommentRange(-1, -1, -1, -1);
-
-    int last = ptr.value;
-    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '\n') {
-      range.setEndLine(startLine);
-      ptr.value += 1;
-      return range;
-    } else if (note[ptr.value] == ':') {
-      range.setStartLine(startLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '-') {
-      range.setStartCharacter(startChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == ':') {
-      range.setEndLine(endLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '\n') {
-      range.setEndCharacter(endChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-    return range;
-  }
-
-  private static PatchSet.Id parsePsId(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    checkResult(patchSetId, "patchset id", changeId);
-    curr.value = endOfLine;
-    return new PatchSet.Id(changeId, patchSetId);
-  }
-
-  private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, ChangeNoteUtil.PARENT_NUMBER, changeId);
-
-    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int parentNumber = RawParseUtils.parseBase10(note, start, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", ChangeNoteUtil.PARENT_NUMBER);
-    }
-    checkResult(parentNumber, "parent number", changeId);
-    curr.value = endOfLine;
-    return Integer.valueOf(parentNumber);
-  }
-
-  private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, ChangeNoteUtil.FILE, changeId);
-    int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    curr.value = endOfLine;
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return QuotedString.GIT_PATH.dequote(
-        RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
-  }
-
-  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    Timestamp commentTime;
-    String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
-    try {
-      commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime());
-    } catch (ParseException e) {
-      throw new ConfigInvalidException("could not parse comment timestamp", e);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentTime, "comment timestamp", changeId);
-  }
-
-  private Account.Id parseAuthor(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId);
-    Account.Id aId = parseIdent(ident, changeId);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, fieldName, changeId);
-  }
-
-  private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, ChangeNoteUtil.LENGTH, changeId);
-    int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    i.value = startOfLength;
-    int commentLength = RawParseUtils.parseBase10(note, startOfLength, i);
-    if (i.value == startOfLength) {
-      throw parseException(changeId, "could not parse %s", ChangeNoteUtil.LENGTH);
-    }
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", ChangeNoteUtil.LENGTH);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentLength, "comment length", changeId);
-  }
-
-  private boolean parseBooleanField(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    String str = parseStringField(note, curr, changeId, fieldName);
-    if ("true".equalsIgnoreCase(str)) {
-      return true;
-    } else if ("false".equalsIgnoreCase(str)) {
-      return false;
-    }
-    throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str);
-  }
-
-  private static <T> T checkResult(T o, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (o == null) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return o;
-  }
-
-  private static int checkResult(int i, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (i <= 0) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return i;
-  }
-
-  private static void checkHeaderLineFormat(
-      byte[] note, MutableInteger curr, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
-    int p = curr.value + fieldName.length();
-    correct &= (p < note.length && note[p] == ':');
-    p++;
-    correct &= (p < note.length && note[p] == ' ');
-    if (!correct) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
deleted file mode 100644
index 2d5293f..0000000
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.inject.Inject;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.sql.Timestamp;
-import java.util.Date;
-import java.util.List;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.util.QuotedString;
-
-public class LegacyChangeNoteWrite {
-
-  private final PersonIdent serverIdent;
-  private final String serverId;
-
-  @Inject
-  public LegacyChangeNoteWrite(
-      @GerritPersonIdent PersonIdent serverIdent, @GerritServerId String serverId) {
-    this.serverIdent = serverIdent;
-    this.serverId = serverId;
-  }
-
-  public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
-    return new PersonIdent(
-        authorId.toString(), authorId.get() + "@" + serverId, when, serverIdent.getTimeZone());
-  }
-
-  public String getServerId() {
-    return serverId;
-  }
-
-  private void appendHeaderField(PrintWriter writer, String field, String value) {
-    writer.print(field);
-    writer.print(": ");
-    writer.print(value);
-    writer.print('\n');
-  }
-
-  /**
-   * Build a note that contains the metadata for and the contents of all of the comments in the
-   * given comments.
-   *
-   * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
-   *     patch sets are allowed since base revisions may be shared across patch sets. All of the
-   *     comments must share the same RevId, and all the comments for a given patch set must have
-   *     the same side.
-   * @param out output stream to write to.
-   */
-  @UsedAt(UsedAt.Project.GOOGLE)
-  public void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
-    if (comments.isEmpty()) {
-      return;
-    }
-
-    ImmutableList<Integer> psIds = comments.keySet().stream().sorted().collect(toImmutableList());
-
-    OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
-    try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      String revId = comments.values().iterator().next().revId;
-      appendHeaderField(writer, ChangeNoteUtil.REVISION, revId);
-
-      for (int psId : psIds) {
-        List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
-        Comment first = psComments.get(0);
-
-        short side = first.side;
-        appendHeaderField(
-            writer,
-            side <= 0 ? ChangeNoteUtil.BASE_PATCH_SET : ChangeNoteUtil.PATCH_SET,
-            Integer.toString(psId));
-        if (side < 0) {
-          appendHeaderField(writer, ChangeNoteUtil.PARENT_NUMBER, Integer.toString(-side));
-        }
-
-        String currentFilename = null;
-
-        for (Comment c : psComments) {
-          checkArgument(
-              revId.equals(c.revId),
-              "All comments being added must have all the same RevId. The "
-                  + "comment below does not have the same RevId as the others "
-                  + "(%s).\n%s",
-              revId,
-              c);
-          checkArgument(
-              side == c.side,
-              "All comments being added must all have the same side. The "
-                  + "comment below does not have the same side as the others "
-                  + "(%s).\n%s",
-              side,
-              c);
-          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);
-
-          if (!commentFilename.equals(currentFilename)) {
-            currentFilename = commentFilename;
-            writer.print("File: ");
-            writer.print(commentFilename);
-            writer.print("\n\n");
-          }
-
-          appendOneComment(writer, c);
-        }
-      }
-    }
-  }
-
-  private void appendOneComment(PrintWriter writer, Comment c) {
-    // The CommentRange field for a comment is allowed to be null. If it is
-    // null, then in the first line, we simply use the line number field for a
-    // comment instead. If it isn't null, we write the comment range itself.
-    Comment.Range range = c.range;
-    if (range != null) {
-      writer.print(range.startLine);
-      writer.print(':');
-      writer.print(range.startChar);
-      writer.print('-');
-      writer.print(range.endLine);
-      writer.print(':');
-      writer.print(range.endChar);
-    } else {
-      writer.print(c.lineNbr);
-    }
-    writer.print("\n");
-
-    writer.print(NoteDbUtil.formatTime(serverIdent, c.writtenOn));
-    writer.print("\n");
-
-    appendIdent(writer, ChangeNoteUtil.AUTHOR, c.author.getId(), c.writtenOn);
-    if (!c.getRealAuthor().equals(c.author)) {
-      appendIdent(writer, ChangeNoteUtil.REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
-    }
-
-    String parent = c.parentUuid;
-    if (parent != null) {
-      appendHeaderField(writer, ChangeNoteUtil.PARENT, parent);
-    }
-
-    appendHeaderField(writer, ChangeNoteUtil.UNRESOLVED, Boolean.toString(c.unresolved));
-    appendHeaderField(writer, ChangeNoteUtil.UUID, c.key.uuid);
-
-    if (c.tag != null) {
-      appendHeaderField(writer, ChangeNoteUtil.TAG, c.tag);
-    }
-
-    byte[] messageBytes = c.message.getBytes(UTF_8);
-    appendHeaderField(writer, ChangeNoteUtil.LENGTH, Integer.toString(messageBytes.length));
-
-    writer.print(c.message);
-    writer.print("\n\n");
-  }
-
-  private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) {
-    PersonIdent ident = newIdent(id, ts, serverIdent);
-    StringBuilder name = new StringBuilder();
-    PersonIdent.appendSanitized(name, ident.getName());
-    name.append(" <");
-    PersonIdent.appendSanitized(name, ident.getEmailAddress());
-    name.append('>');
-    appendHeaderField(writer, header, name.toString());
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index 61f475f..18ffd17 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -45,7 +46,8 @@
 
   @Inject
   NoteDbMetrics(MetricMaker metrics) {
-    Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
+    Field<NoteDbTable> tableField =
+        Field.ofEnum(NoteDbTable.class, "table", Metadata.Builder::noteDbTable).build();
 
     updateLatency =
         metrics.newTimer(
@@ -53,7 +55,7 @@
             new Description("NoteDb update latency by table")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            tableField);
 
     stageUpdateLatency =
         metrics.newTimer(
@@ -61,7 +63,7 @@
             new Description("Latency for staging updates to NoteDb by table")
                 .setCumulative()
                 .setUnit(Units.MICROSECONDS),
-            view);
+            tableField);
 
     readLatency =
         metrics.newTimer(
@@ -69,7 +71,7 @@
             new Description("NoteDb read latency by table")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            tableField);
 
     parseLatency =
         metrics.newTimer(
@@ -77,6 +79,6 @@
             new Description("NoteDb parse latency by table")
                 .setCumulative()
                 .setUnit(Units.MICROSECONDS),
-            view);
+            tableField);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index ea42a9d..7022cdc 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,27 +18,21 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.InsertedObject;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -50,9 +44,9 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -70,85 +64,16 @@
  * {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
-  private static final ImmutableList<String> PACKAGE_PREFIXES =
-      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
-  private static final ImmutableSet<String> SERVLET_NAMES =
-      ImmutableSet.of(
-          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
-
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
 
-  public static class OpenRepo implements AutoCloseable {
-    public final Repository repo;
-    public final RevWalk rw;
-    public final ChainedReceiveCommands cmds;
-
-    private final InMemoryInserter inMemIns;
-    private final ObjectInserter tempIns;
-    @Nullable private final ObjectInserter finalIns;
-
-    private final boolean close;
-
-    private OpenRepo(
-        Repository repo,
-        RevWalk rw,
-        @Nullable ObjectInserter ins,
-        ChainedReceiveCommands cmds,
-        boolean close) {
-      ObjectReader reader = rw.getObjectReader();
-      checkArgument(
-          ins == null || reader.getCreatedFromInserter() == ins,
-          "expected reader to be created from %s, but was %s",
-          ins,
-          reader.getCreatedFromInserter());
-      this.repo = requireNonNull(repo);
-
-      this.inMemIns = new InMemoryInserter(rw.getObjectReader());
-      this.tempIns = inMemIns;
-
-      this.rw = new RevWalk(tempIns.newReader());
-      this.finalIns = ins;
-      this.cmds = requireNonNull(cmds);
-      this.close = close;
-    }
-
-    public Optional<ObjectId> getObjectId(String refName) throws IOException {
-      return cmds.get(refName);
-    }
-
-    void flush() throws IOException {
-      flushToFinalInserter();
-      finalIns.flush();
-    }
-
-    void flushToFinalInserter() throws IOException {
-      checkState(finalIns != null);
-      for (InsertedObject obj : inMemIns.getInsertedObjects()) {
-        finalIns.insert(obj.type(), obj.data().toByteArray());
-      }
-      inMemIns.clear();
-    }
-
-    @Override
-    public void close() {
-      rw.getObjectReader().close();
-      rw.close();
-      if (close) {
-        if (finalIns != null) {
-          finalIns.close();
-        }
-        repo.close();
-      }
-    }
-  }
-
   private final Provider<PersonIdent> serverIdent;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final NoteDbMetrics metrics;
   private final Project.NameKey projectName;
+  private final int maxUpdates;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
@@ -157,6 +82,7 @@
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
+  private AllUsersAsyncUpdate updateAllUsersAsync;
   private boolean executed;
   private String refLogMessage;
   private PersonIdent refLogIdent;
@@ -164,16 +90,20 @@
 
   @Inject
   NoteDbUpdateManager(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
+      AllUsersAsyncUpdate updateAllUsersAsync,
       @Assisted Project.NameKey projectName) {
     this.serverIdent = serverIdent;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.metrics = metrics;
+    this.updateAllUsersAsync = updateAllUsersAsync;
     this.projectName = projectName;
+    maxUpdates = cfg.getInt("change", null, "maxUpdates", 1000);
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -236,28 +166,13 @@
 
   private void initChangeRepo() throws IOException {
     if (changeRepo == null) {
-      changeRepo = openRepo(projectName);
+      changeRepo = OpenRepo.open(repoManager, projectName);
     }
   }
 
   private void initAllUsersRepo() throws IOException {
     if (allUsersRepo == null) {
-      allUsersRepo = openRepo(allUsersName);
-    }
-  }
-
-  private OpenRepo openRepo(Project.NameKey p) throws IOException {
-    Repository repo = repoManager.openRepository(p); // Closed by OpenRepo#close.
-    ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
-    ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
-    try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
-      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true) {
-        @Override
-        public void close() {
-          reader.close();
-          super.close();
-        }
-      };
+      allUsersRepo = OpenRepo.open(repoManager, allUsersName);
     }
   }
 
@@ -268,7 +183,8 @@
         && rewriters.isEmpty()
         && toDelete.isEmpty()
         && !hasCommands(changeRepo)
-        && !hasCommands(allUsersRepo);
+        && !hasCommands(allUsersRepo)
+        && updateAllUsersAsync.isEmpty();
   }
 
   private static boolean hasCommands(@Nullable OpenRepo or) {
@@ -351,7 +267,7 @@
    * @throws IOException if a storage layer error occurs.
    */
   private void stage() throws IOException {
-    try (Timer1.Context timer = metrics.stageUpdateLatency.start(CHANGES)) {
+    try (Timer1.Context<NoteDbTable> timer = metrics.stageUpdateLatency.start(CHANGES)) {
       if (isEmpty()) {
         return;
       }
@@ -364,16 +280,6 @@
     }
   }
 
-  public void flush() throws IOException {
-    checkNotExecuted();
-    if (changeRepo != null) {
-      changeRepo.flush();
-    }
-    if (allUsersRepo != null) {
-      allUsersRepo.flush();
-    }
-  }
-
   @Nullable
   public BatchRefUpdate execute() throws IOException {
     return execute(false);
@@ -386,7 +292,7 @@
       executed = true;
       return null;
     }
-    try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
+    try (Timer1.Context<NoteDbTable> timer = metrics.updateLatency.start(CHANGES)) {
       stage();
       // ChangeUpdates must execute before ChangeDraftUpdates.
       //
@@ -398,6 +304,13 @@
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
       BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
       execute(allUsersRepo, dryrun, null);
+      if (!dryrun) {
+        // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
+        // have to run synchronous to be of any value at all. For the removal of draft comments from
+        // All-Users we don't care much of the operation succeeds, so we are skipping the dry run
+        // altogether.
+        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert);
+      }
       executed = true;
       return result;
     } finally {
@@ -423,7 +336,8 @@
     if (refLogMessage != null) {
       bru.setRefLogMessage(refLogMessage, false);
     } else {
-      bru.setRefLogMessage(firstNonNull(guessRestApiHandler(), "Update NoteDb refs"), false);
+      bru.setRefLogMessage(
+          firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs"), false);
     }
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     bru.setAtomic(true);
@@ -436,59 +350,18 @@
     return bru;
   }
 
-  private static String guessRestApiHandler() {
-    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
-    int i = findRestApiServlet(trace);
-    if (i < 0) {
-      return null;
-    }
-    try {
-      for (i--; i >= 0; i--) {
-        String cn = trace[i].getClassName();
-        Class<?> cls = Class.forName(cn);
-        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
-          return viewName(cn);
-        }
-      }
-      return null;
-    } catch (ClassNotFoundException e) {
-      return null;
-    }
-  }
-
-  private static String viewName(String cn) {
-    String impl = cn.replace('$', '.');
-    for (String p : PACKAGE_PREFIXES) {
-      if (impl.startsWith(p)) {
-        return impl.substring(p.length());
-      }
-    }
-    return impl;
-  }
-
-  private static int findRestApiServlet(StackTraceElement[] trace) {
-    for (int i = 0; i < trace.length; i++) {
-      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
   private void addCommands() throws IOException {
-    if (isEmpty()) {
-      return;
-    }
-    checkState(changeRepo != null, "must set change repo");
+    changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates));
     if (!draftUpdates.isEmpty()) {
-      checkState(allUsersRepo != null, "must set all users repo");
-    }
-    addUpdates(changeUpdates, changeRepo);
-    if (!draftUpdates.isEmpty()) {
-      addUpdates(draftUpdates, allUsersRepo);
+      boolean publishOnly = draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+      if (publishOnly) {
+        updateAllUsersAsync.setDraftUpdates(draftUpdates);
+      } else {
+        allUsersRepo.addUpdates(draftUpdates);
+      }
     }
     if (!robotCommentUpdates.isEmpty()) {
-      addUpdates(robotCommentUpdates, changeRepo);
+      changeRepo.addUpdates(robotCommentUpdates);
     }
     if (!rewriters.isEmpty()) {
       addRewrites(rewriters, changeRepo);
@@ -520,36 +393,6 @@
     checkState(!executed, "update has already been executed");
   }
 
-  private static <U extends AbstractChangeUpdate> void addUpdates(
-      ListMultimap<String, U> all, OpenRepo or) throws IOException {
-    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
-      String refName = e.getKey();
-      Collection<U> updates = e.getValue();
-      ObjectId old = or.cmds.get(refName).orElse(ObjectId.zeroId());
-      // Only actually write to the ref if one of the updates explicitly allows
-      // us to do so, i.e. it is known to represent a new change. This avoids
-      // writing partial change meta if the change hasn't been backfilled yet.
-      if (!allowWrite(updates, old)) {
-        continue;
-      }
-
-      ObjectId curr = old;
-      for (U u : updates) {
-        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
-          throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
-        }
-        ObjectId next = u.apply(or.rw, or.tempIns, curr);
-        if (next == null) {
-          continue;
-        }
-        curr = next;
-      }
-      if (!old.equals(curr)) {
-        or.cmds.add(new ReceiveCommand(old, curr, refName));
-      }
-    }
-  }
-
   private static void addRewrites(ListMultimap<String, NoteDbRewriter> rewriters, OpenRepo openRepo)
       throws IOException {
     for (Map.Entry<String, Collection<NoteDbRewriter>> entry : rewriters.asMap().entrySet()) {
@@ -578,12 +421,4 @@
       }
     }
   }
-
-  private static <U extends AbstractChangeUpdate> boolean allowWrite(
-      Collection<U> updates, ObjectId old) {
-    if (!old.equals(ObjectId.zeroId())) {
-      return true;
-    }
-    return updates.iterator().next().allowWriteToNewRef();
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 667ceab..58a33c8 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import java.sql.Timestamp;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -25,26 +29,35 @@
 
 public class NoteDbUtil {
 
-  /**
-   * Returns an AccountId for the given email address. Returns empty if the address isn't on this
-   * server.
-   */
-  public static Optional<Account.Id> parseIdent(PersonIdent ident, String serverId) {
+  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+
+  private static final ImmutableList<String> PACKAGE_PREFIXES =
+      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
+  private static final ImmutableSet<String> SERVLET_NAMES =
+      ImmutableSet.of(
+          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
+
+  /** Returns an AccountId for the given email address. */
+  public static Optional<Account.Id> parseIdent(PersonIdent ident) {
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
     if (at >= 0) {
-      String host = email.substring(at + 1);
-      if (host.equals(serverId)) {
-        Integer id = Ints.tryParse(email.substring(0, at));
-        if (id != null) {
-          return Optional.of(new Account.Id(id));
-        }
+      Integer id = Ints.tryParse(email.substring(0, at));
+      if (id != null) {
+        return Optional.of(Account.id(id));
       }
     }
     return Optional.empty();
   }
 
-  private NoteDbUtil() {}
+  public static String extractHostPartFromPersonIdent(PersonIdent ident) {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      return email.substring(at + 1);
+    }
+    throw new IllegalArgumentException("No host part found: " + email);
+  }
 
   public static String formatTime(PersonIdent ident, Timestamp t) {
     GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
@@ -53,7 +66,29 @@
     return dateFormatter.formatDate(newIdent);
   }
 
-  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+  /**
+   * Returns the name of the REST API handler that is in the stack trace of the caller of this
+   * method.
+   */
+  static String guessRestApiHandler() {
+    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+    int i = findRestApiServlet(trace);
+    if (i < 0) {
+      return null;
+    }
+    try {
+      for (i--; i >= 0; i--) {
+        String cn = trace[i].getClassName();
+        Class<?> cls = Class.forName(cn);
+        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
+          return viewName(cn);
+        }
+      }
+      return null;
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
 
   static String sanitizeFooter(String value) {
     // Remove characters that would confuse JGit's footer parser if they were
@@ -65,4 +100,25 @@
     // empty paragraph for the purposes of footer parsing.
     return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
   }
+
+  private static int findRestApiServlet(StackTraceElement[] trace) {
+    for (int i = 0; i < trace.length; i++) {
+      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  private static String viewName(String cn) {
+    String impl = cn.replace('$', '.');
+    for (String p : PACKAGE_PREFIXES) {
+      if (impl.startsWith(p)) {
+        return impl.substring(p.length());
+      }
+    }
+    return impl;
+  }
+
+  private NoteDbUtil() {}
 }
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
new file mode 100644
index 0000000..de88684
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.InsertedObject;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Wrapper around {@link Repository} that keeps track of related {@link ObjectInserter}s and other
+ * objects that are jointly closed when invoking {@link #close}.
+ */
+class OpenRepo implements AutoCloseable {
+  /** Returns a {@link OpenRepo} wrapping around an open {@link Repository}. */
+  static OpenRepo open(GitRepositoryManager repoManager, Project.NameKey project)
+      throws IOException {
+    Repository repo = repoManager.openRepository(project); // Closed by OpenRepo#close.
+    ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
+    ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
+    try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
+      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true) {
+        @Override
+        public void close() {
+          reader.close();
+          super.close();
+        }
+      };
+    }
+  }
+
+  final Repository repo;
+  final RevWalk rw;
+  final ChainedReceiveCommands cmds;
+  final ObjectInserter tempIns;
+
+  private final InMemoryInserter inMemIns;
+  @Nullable private final ObjectInserter finalIns;
+  private final boolean close;
+
+  OpenRepo(
+      Repository repo,
+      RevWalk rw,
+      @Nullable ObjectInserter ins,
+      ChainedReceiveCommands cmds,
+      boolean close) {
+    ObjectReader reader = rw.getObjectReader();
+    checkArgument(
+        ins == null || reader.getCreatedFromInserter() == ins,
+        "expected reader to be created from %s, but was %s",
+        ins,
+        reader.getCreatedFromInserter());
+    this.repo = requireNonNull(repo);
+
+    this.inMemIns = new InMemoryInserter(rw.getObjectReader());
+    this.tempIns = inMemIns;
+
+    this.rw = new RevWalk(tempIns.newReader());
+    this.finalIns = ins;
+    this.cmds = requireNonNull(cmds);
+    this.close = close;
+  }
+
+  @Override
+  public void close() {
+    rw.getObjectReader().close();
+    rw.close();
+    if (close) {
+      if (finalIns != null) {
+        finalIns.close();
+      }
+      repo.close();
+    }
+  }
+
+  void flush() throws IOException {
+    flushToFinalInserter();
+    finalIns.flush();
+  }
+
+  void flushToFinalInserter() throws IOException {
+    checkState(finalIns != null);
+    for (InsertedObject obj : inMemIns.getInsertedObjects()) {
+      finalIns.insert(obj.type(), obj.data().toByteArray());
+    }
+    inMemIns.clear();
+  }
+
+  private static <U extends AbstractChangeUpdate> boolean allowWrite(
+      Collection<U> updates, ObjectId old) {
+    if (!old.equals(ObjectId.zeroId())) {
+      return true;
+    }
+    return updates.iterator().next().allowWriteToNewRef();
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(ListMultimap<String, U> all) throws IOException {
+    addUpdates(all, Optional.empty());
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(
+      ListMultimap<String, U> all, Optional<Integer> maxUpdates) throws IOException {
+    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
+      String refName = e.getKey();
+      Collection<U> updates = e.getValue();
+      ObjectId old = cmds.get(refName).orElse(ObjectId.zeroId());
+      // Only actually write to the ref if one of the updates explicitly allows
+      // us to do so, i.e. it is known to represent a new change. This avoids
+      // writing partial change meta if the change hasn't been backfilled yet.
+      if (!allowWrite(updates, old)) {
+        continue;
+      }
+
+      int updateCount;
+      U first = updates.iterator().next();
+      if (maxUpdates.isPresent()) {
+        checkState(first.getNotes() != null, "expected ChangeNotes on %s", first);
+        updateCount = first.getNotes().getUpdateCount();
+      } else {
+        updateCount = 0;
+      }
+
+      ObjectId curr = old;
+      for (U u : updates) {
+        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
+          throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
+        }
+        ObjectId next = u.apply(rw, tempIns, curr);
+        if (next == null) {
+          continue;
+        }
+        if (maxUpdates.isPresent()
+            && !Objects.equals(next, curr)
+            && ++updateCount > maxUpdates.get()
+            && !u.bypassMaxUpdates()) {
+          throw new TooManyUpdatesException(u.getId(), maxUpdates.get());
+        }
+        curr = next;
+      }
+      if (!old.equals(curr)) {
+        cmds.add(new ReceiveCommand(old, curr, refName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 1c33a68..8096f89 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
+import static com.google.gerrit.entities.RefNames.REFS;
+import static com.google.gerrit.entities.RefNames.REFS_SEQUENCES;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -29,18 +29,19 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Lock;
@@ -69,9 +70,12 @@
   }
 
   @VisibleForTesting
-  static RetryerBuilder<RefUpdate> retryerBuilder() {
-    return RetryerBuilder.<RefUpdate>newBuilder()
-        .retryIfResult(ru -> ru != null && RefUpdate.Result.LOCK_FAILURE.equals(ru.getResult()))
+  static RetryerBuilder<ImmutableList<Integer>> retryerBuilder() {
+    return RetryerBuilder.<ImmutableList<Integer>>newBuilder()
+        .retryIfException(
+            t ->
+                t instanceof StorageException
+                    && ((StorageException) t).getCause() instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
                 WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
@@ -79,7 +83,7 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
   }
 
-  private static final Retryer<RefUpdate> RETRYER = retryerBuilder().build();
+  private static final Retryer<ImmutableList<Integer>> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
@@ -89,7 +93,7 @@
   private final int floor;
   private final int batchSize;
   private final Runnable afterReadRef;
-  private final Retryer<RefUpdate> retryer;
+  private final Retryer<ImmutableList<Integer>> retryer;
 
   // Protects all non-final fields.
   private final Lock counterLock;
@@ -147,7 +151,7 @@
       Seed seed,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate> retryer) {
+      Retryer<ImmutableList<Integer>> retryer) {
     this(repoManager, gitRefUpdated, projectName, name, seed, batchSize, afterReadRef, retryer, 0);
   }
 
@@ -159,7 +163,7 @@
       Seed seed,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate> retryer,
+      Retryer<ImmutableList<Integer>> retryer,
       int floor) {
     this.repoManager = requireNonNull(repoManager, "repoManager");
     this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
@@ -184,78 +188,85 @@
     counterLock = new ReentrantLock(true);
   }
 
+  /**
+   * Retrieves the next available sequence number.
+   *
+   * <p>This method is thread-safe.
+   *
+   * @return the next available sequence number
+   */
   public int next() {
-    counterLock.lock();
-    try {
-      if (counter >= limit) {
-        acquire(batchSize);
-      }
-      return counter++;
-    } finally {
-      counterLock.unlock();
-    }
+    return Iterables.getOnlyElement(next(1));
   }
 
+  /**
+   * Retrieves the next N available sequence number.
+   *
+   * <p>This method is thread-safe.
+   *
+   * @param count the number of sequence numbers which should be returned
+   * @return the next N available sequence numbers
+   */
   public ImmutableList<Integer> next(int count) {
     if (count == 0) {
       return ImmutableList.of();
     }
     checkArgument(count > 0, "count is negative: %s", count);
-    counterLock.lock();
-    try {
-      List<Integer> ids = new ArrayList<>(count);
-      while (counter < limit) {
-        ids.add(counter++);
-        if (ids.size() == count) {
-          return ImmutableList.copyOf(ids);
-        }
-      }
-      acquire(Math.max(count - ids.size(), batchSize));
-      while (ids.size() < count) {
-        ids.add(counter++);
-      }
-      return ImmutableList.copyOf(ids);
-    } finally {
-      counterLock.unlock();
-    }
-  }
 
-  private void acquire(int count) {
-    try (Repository repo = repoManager.openRepository(projectName);
-        RevWalk rw = new RevWalk(repo)) {
-      TryAcquire attempt = new TryAcquire(repo, rw, count);
-      RefUpdateUtil.checkResult(retryer.call(attempt));
-      counter = attempt.next;
-      limit = counter + count;
-      acquireCount++;
+    try {
+      return retryer.call(
+          () -> {
+            counterLock.lock();
+            try {
+              if (count == 1) {
+                if (counter >= limit) {
+                  acquire(batchSize);
+                }
+                return ImmutableList.of(counter++);
+              }
+
+              List<Integer> ids = new ArrayList<>(count);
+              while (counter < limit) {
+                ids.add(counter++);
+                if (ids.size() == count) {
+                  return ImmutableList.copyOf(ids);
+                }
+              }
+              acquire(Math.max(count - ids.size(), batchSize));
+              while (ids.size() < count) {
+                ids.add(counter++);
+              }
+              return ImmutableList.copyOf(ids);
+            } finally {
+              counterLock.unlock();
+            }
+          });
     } catch (ExecutionException | RetryException e) {
       if (e.getCause() != null) {
         Throwables.throwIfInstanceOf(e.getCause(), StorageException.class);
       }
       throw new StorageException(e);
-    } catch (IOException e) {
-      throw new StorageException(e);
     }
   }
 
-  private class TryAcquire implements Callable<RefUpdate> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final int count;
-
-    private int next;
-
-    private TryAcquire(Repository repo, RevWalk rw, int count) {
-      this.repo = repo;
-      this.rw = rw;
-      this.count = count;
-    }
-
-    @Override
-    public RefUpdate call() throws Exception {
+  /**
+   * Updates the next available sequence number in NoteDb in order to have a batch of sequence
+   * numbers available that can be handed out. {@link #counter} stores the next sequence number that
+   * can be handed out. When {@link #limit} is reached a new batch of sequence numbers needs to be
+   * retrieved by calling this method.
+   *
+   * <p><strong>Note:</strong> Callers are required to acquire the {@link #counterLock} before
+   * calling this method.
+   *
+   * @param count the number of sequence numbers which should be retrieved
+   */
+  private void acquire(int count) {
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
       Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
       afterReadRef.run();
       ObjectId oldId;
+      int next;
       if (!blob.isPresent()) {
         oldId = ObjectId.zeroId();
         next = seed.get();
@@ -264,7 +275,14 @@
         next = blob.get().value();
       }
       next = Math.max(floor, next);
-      return IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
+      RefUpdate refUpdate =
+          IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
+      RefUpdateUtil.checkResult(refUpdate);
+      counter = next;
+      limit = counter + count;
+      acquireCount++;
+    } catch (IOException e) {
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index fad9832..d5a7259 100644
--- a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -21,13 +21,13 @@
 /** State of a reviewer on a change. */
 public enum ReviewerStateInternal {
   /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER(new FooterKey("Reviewer"), ReviewerState.REVIEWER),
+  REVIEWER("Reviewer", ReviewerState.REVIEWER),
 
   /** The reviewer was added to the change, but has not voted. */
-  CC(new FooterKey("CC"), ReviewerState.CC),
+  CC("CC", ReviewerState.CC),
 
   /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
+  REMOVED("Removed", ReviewerState.REMOVED);
 
   public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
     return ReviewerStateInternal.values()[state.ordinal()];
@@ -50,20 +50,20 @@
     }
   }
 
-  private final FooterKey footerKey;
+  private final String footer;
   private final ReviewerState state;
 
-  ReviewerStateInternal(FooterKey footerKey, ReviewerState state) {
-    this.footerKey = footerKey;
+  ReviewerStateInternal(String footer, ReviewerState state) {
+    this.footer = footer;
     this.state = state;
   }
 
   FooterKey getFooterKey() {
-    return footerKey;
+    return new FooterKey(footer);
   }
 
   FooterKey getByEmailFooterKey() {
-    return new FooterKey(footerKey.getName() + "-email");
+    return new FooterKey(footer + "-email");
   }
 
   public ReviewerState asReviewerState() {
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index ac7a89d..e63737c 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Comment;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -33,27 +32,29 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 class RevisionNoteBuilder {
   static class Cache {
     private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
-    private final Map<RevId, RevisionNoteBuilder> builders;
+    private final Map<ObjectId, RevisionNoteBuilder> builders;
 
     Cache(RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
 
-    RevisionNoteBuilder get(RevId revId) {
-      RevisionNoteBuilder b = builders.get(revId);
+    RevisionNoteBuilder get(AnyObjectId commitId) {
+      RevisionNoteBuilder b = builders.get(commitId);
       if (b == null) {
-        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(revId));
-        builders.put(revId, b);
+        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(commitId));
+        builders.put(commitId.copy(), b);
       }
       return b;
     }
 
-    Map<RevId, RevisionNoteBuilder> getBuilders() {
+    Map<ObjectId, RevisionNoteBuilder> getBuilders() {
       return Collections.unmodifiableMap(builders);
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index 1e16b22..c0e09ed 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.List;
 
 /**
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index da790e2..3e1bad1 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -15,37 +15,28 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Comment;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
 class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, T> revisionNotes;
+  final ImmutableMap<ObjectId, T> revisionNotes;
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson,
-      LegacyChangeNoteRead legacyChangeNoteRead,
-      Change.Id changeId,
-      ObjectReader reader,
-      NoteMap noteMap,
-      PatchLineComment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
-    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
+    Map<ObjectId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
-      ChangeRevisionNote rn =
-          new ChangeRevisionNote(
-              noteJson, legacyChangeNoteRead, changeId, reader, note.getData(), status);
+      ChangeRevisionNote rn = new ChangeRevisionNote(noteJson, reader, note.getData(), status);
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
@@ -53,12 +44,12 @@
   static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws ConfigInvalidException, IOException {
-    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    Map<ObjectId, RobotCommentsRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
       RobotCommentsRevisionNote rn =
           new RobotCommentsRevisionNote(changeNoteJson, reader, note.getData());
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
@@ -67,7 +58,7 @@
     return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.of());
   }
 
-  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<RevId, T> revisionNotes) {
+  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index 92dd7d8..fe05643 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -19,11 +19,10 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.RobotComment;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -42,7 +41,7 @@
 
   private final Change change;
 
-  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private ImmutableListMultimap<ObjectId, RobotComment> comments;
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
@@ -56,7 +55,7 @@
     return revisionNoteMap;
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+  public ImmutableListMultimap<ObjectId, RobotComment> getComments() {
     return comments;
   }
 
@@ -95,10 +94,10 @@
     revisionNoteMap =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<RevId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (RobotComment c : rn.getEntities()) {
-        cs.put(new RevId(c.revId), c);
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 0304ab8..895f378 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -15,16 +15,15 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
+import static com.google.gerrit.entities.RefNames.robotCommentsRef;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -103,19 +102,19 @@
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (RobotComment c : put) {
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     boolean hasComments = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId id = e.getKey();
+      updatedRevs.add(id);
       byte[] data = e.getValue().build(noteUtil);
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
index 6c3cc86..97a8ad4 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -16,7 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.entities.RobotComment;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
index 116b30e..3619c43 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.entities.RobotComment;
 import java.util.List;
 
 public class RobotCommentsRevisionNoteData {
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index 9e8c541..73cc600 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Config;
@@ -95,30 +96,35 @@
             new Description("Latency of requesting IDs from repo sequences")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofEnum(SequenceType.class, "sequence"),
-            Field.ofBoolean("multiple"));
+            Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
+                .build(),
+            Field.ofBoolean("multiple", Metadata.Builder::multiple).build());
   }
 
   public int nextAccountId() {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
       return accountSeq.next();
     }
   }
 
   public int nextChangeId() {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.CHANGES, false)) {
       return changeSeq.next();
     }
   }
 
   public ImmutableList<Integer> nextChangeIds(int count) {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
       return changeSeq.next(count);
     }
   }
 
   public int nextGroupId() {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.GROUPS, false)) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.GROUPS, false)) {
       return groupSeq.next();
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
new file mode 100644
index 0000000..9c6faaf
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+
+/**
+ * Exception indicating that the change has received too many updates. Further actions apart from
+ * {@code abandon} or {@code submit} are blocked.
+ */
+public class TooManyUpdatesException extends StorageException {
+  @VisibleForTesting
+  public static String message(Change.Id id, int maxUpdates) {
+    return "Change "
+        + id
+        + " may not exceed "
+        + maxUpdates
+        + " updates. It may still be abandoned or submitted. To continue working on this "
+        + "change, recreate it with a new Change-Id, then abandon this one.";
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  TooManyUpdatesException(Change.Id id, int maxUpdates) {
+    super(message(id, maxUpdates));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 18aa8b9..a9cc9b5 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -19,7 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index 9153638..b61e0c7 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiff.java b/java/com/google/gerrit/server/patch/IntraLineDiff.java
index 1c3d78a..fb13207 100644
--- a/java/com/google/gerrit/server/patch/IntraLineDiff.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -21,8 +21,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.CodedEnum;
 import com.google.gerrit.jgit.diff.ReplaceEdit;
-import com.google.gerrit.reviewdb.client.CodedEnum;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.ObjectInputStream;
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java b/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
index 4661485..2ebae02 100644
--- a/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
@@ -17,7 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 0e5bcc1..ca5223d 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -16,8 +16,8 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.exceptions.NoSuchEntityException;
-import com.google.gerrit.reviewdb.client.Patch;
 import java.io.IOException;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -39,6 +39,15 @@
   private final RevTree aTree;
   private final RevTree bTree;
 
+  // Full text of both sides of the file. For standard files, these are not directly reconstructable
+  // from the PatchListEntry, which comes from the PatchListCache and only contains the diff between
+  // the two blobs. This is intentional, to avoid storing entire large blobs in the cache. For
+  // regular files, the full text is initialized from the repo lazily only when necessary, e.g. in
+  // getLine. Although it's a safe assumption that any caller constructing a PatchSet will want to
+  // read some content, we don't know in advance which side they are interested in.
+  //
+  // For special files like COMMIT_MSG, the full text is loaded eagerly during the constructor.
+  // TODO(dborowitz): I see why the logic is different, but I don't see why it needs to be eager.
   private Text a;
   private Text b;
 
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index dd717ba..fee9088 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -25,8 +25,9 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -78,7 +79,7 @@
       boolean isMerge,
       ComparisonType comparisonType,
       PatchListEntry[] patches) {
-    this.oldId = oldId != null ? oldId.copy() : null;
+    this.oldId = ObjectIds.copyOrNull(oldId);
     this.newId = newId.copy();
     this.isMerge = isMerge;
     this.comparisonType = comparisonType;
diff --git a/java/com/google/gerrit/server/patch/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
index 728d227..63cac0e 100644
--- a/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Provides a cached list of {@link PatchListEntry}. */
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 20f0f72..15fa0f4 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -18,10 +18,10 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -137,10 +137,7 @@
   private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
-    if (patchSet.getRevision() == null) {
-      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
-    }
-    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    ObjectId b = patchSet.commitId();
     Whitespace ws = Whitespace.IGNORE_NONE;
     if (parentNum != null) {
       return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index 6b1a153..625f56c 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -30,10 +30,10 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.Patch.PatchType;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.entities.PatchSet;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -229,7 +229,7 @@
   }
 
   Patch toPatch(PatchSet.Id setId) {
-    final Patch p = new Patch(new Patch.Key(setId, getNewName()));
+    final Patch p = new Patch(Patch.key(setId, getNewName()));
     p.setChangeType(getChangeType());
     p.setPatchType(getPatchType());
     p.setSourceFileName(getOldName());
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index 2df6d66..bf38029 100644
--- a/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
@@ -82,7 +83,7 @@
   private transient Whitespace whitespace;
 
   private PatchListKey(AnyObjectId a, AnyObjectId b, Whitespace ws) {
-    oldId = a != null ? a.copy() : null;
+    oldId = ObjectIds.copyOrNull(a);
     newId = b.copy();
     whitespace = ws;
   }
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index 08de537..c3d9a1d 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -28,9 +28,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -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/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index c2caa01..1c1c639 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -21,14 +21,14 @@
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.prettify.common.EditList;
 import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
@@ -37,6 +37,7 @@
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
@@ -120,7 +121,6 @@
 
   private PatchScript build(PatchListEntry content, CommentDetail comments, List<Patch> history)
       throws IOException {
-    boolean intralineDifferenceIsPossible = true;
     boolean intralineFailure = false;
     boolean intralineTimeout = false;
 
@@ -146,21 +146,17 @@
             break;
 
           case DISABLED:
-            intralineDifferenceIsPossible = false;
             break;
 
           case ERROR:
-            intralineDifferenceIsPossible = false;
             intralineFailure = true;
             break;
 
           case TIMEOUT:
-            intralineDifferenceIsPossible = false;
             intralineTimeout = true;
             break;
         }
       } else {
-        intralineDifferenceIsPossible = false;
         intralineFailure = true;
       }
     }
@@ -220,7 +216,6 @@
         comments,
         history,
         hugeFile,
-        intralineDifferenceIsPossible,
         intralineFailure,
         intralineTimeout,
         content.getPatchType() == Patch.PatchType.BINARY,
@@ -514,8 +509,7 @@
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
@@ -534,8 +528,7 @@
           }
           reuse = false;
         } else if (Patch.MERGE_LIST.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index a7e7738..319a5bc 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,20 +15,21 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -136,7 +137,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
   }
 
   @AssistedInject
@@ -172,7 +173,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
@@ -188,19 +189,28 @@
   public PatchScript call()
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    if (parentNum < 0) {
-      validatePatchSetId(psa);
-    }
+    validatePatchSetId(psa);
     validatePatchSetId(psb);
 
-    PatchSet psEntityA = psa != null ? psUtil.get(notes, psa) : null;
-    PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(notes, psb);
-    if (psEntityA != null || psEntityB != null) {
-      try {
-        permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-      } catch (AuthException e) {
-        throw new NoSuchChangeException(changeId, e);
-      }
+    if (psa != null) {
+      checkState(parentNum < 0, "expected no parentNum when psa is present");
+      checkArgument(psa.get() != 0, "edit not supported for left side");
+      aId = getCommitId(psa);
+    } else {
+      aId = null;
+    }
+
+    if (psb.get() != 0) {
+      bId = getCommitId(psb);
+    } else {
+      // Change edit: create synthetic PatchSet corresponding to the edit.
+      bId = getEditRev();
+    }
+
+    try {
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new NoSuchChangeException(changeId, e);
     }
 
     if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
@@ -208,11 +218,6 @@
     }
 
     try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-      bId = toObjectId(psEntityB);
-      if (parentNum < 0) {
-        aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      }
-
       try {
         final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
         final PatchScriptBuilder b = newBuilder(list, git);
@@ -258,20 +263,12 @@
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException {
-    if (ps.getId().get() == 0) {
-      return getEditRev();
+  private ObjectId getCommitId(PatchSet.Id psId) {
+    PatchSet ps = psUtil.get(notes, psId);
+    if (ps == null) {
+      throw new NoSuchChangeException(psId.changeId());
     }
-    if (ps.getRevision() == null || ps.getRevision().get() == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Patch set %s has invalid revision", ps.getId());
-      throw new NoSuchChangeException(changeId, e);
-    }
+    return ps.commitId();
   }
 
   private ObjectId getEditRev() throws AuthException, IOException {
@@ -284,7 +281,7 @@
 
   private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
-    } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
+    } else if (changeId.equals(psId.changeId())) { // OK, same change;
     } else {
       throw new NoSuchChangeException(changeId);
     }
@@ -306,7 +303,7 @@
           switch (changeType) {
             case COPIED:
             case RENAMED:
-              if (ps.getId().equals(psa)) {
+              if (ps.id().equals(psa)) {
                 name = oldName;
               }
               break;
@@ -319,12 +316,12 @@
           }
         }
 
-        Patch p = new Patch(new Patch.Key(ps.getId(), name));
+        Patch p = new Patch(Patch.key(ps.id(), name));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
       if (edit != null && edit.isPresent()) {
-        Patch p = new Patch(new Patch.Key(new PatchSet.Id(psb.getParentKey(), 0), fileName));
+        Patch p = new Patch(Patch.key(PatchSet.id(psb.changeId(), 0), fileName));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
@@ -385,8 +382,8 @@
   private void loadPublished(Map<Patch.Key, Patch> byKey, String file) {
     for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setCommentCount(p.getCommentCount() + 1);
@@ -397,8 +394,8 @@
   private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file) {
     for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setDraftCount(p.getDraftCount() + 1);
diff --git a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 0eb5588..019fe15 100644
--- a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -28,13 +25,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Set;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -58,9 +51,9 @@
     PatchSetInfo info = new PatchSetInfo(psi);
     info.setSubject(src.getShortMessage());
     info.setMessage(src.getFullMessage());
-    info.setAuthor(toUserIdentity(src.getAuthorIdent()));
-    info.setCommitter(toUserIdentity(src.getCommitterIdent()));
-    info.setRevId(src.getName());
+    info.setAuthor(emails.toUserIdentity(src.getAuthorIdent()));
+    info.setCommitter(emails.toUserIdentity(src.getCommitterIdent()));
+    info.setCommitId(src);
     return info;
   }
 
@@ -78,8 +71,8 @@
       throws PatchSetInfoNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      final RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
-      PatchSetInfo info = get(rw, src, patchSet.getId());
+      RevCommit src = rw.parseCommit(patchSet.commitId());
+      PatchSetInfo info = get(rw, src, patchSet.id());
       info.setParents(toParentInfos(src.getParents(), rw));
       return info;
     } catch (IOException | StorageException e) {
@@ -87,33 +80,13 @@
     }
   }
 
-  // TODO: The same method exists in EventFactory, find a common place for it
-  private UserIdentity toUserIdentity(PersonIdent who) throws IOException {
-    final UserIdentity u = new UserIdentity();
-    u.setName(who.getName());
-    u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
-    u.setTimeZone(who.getTimeZoneOffset());
-
-    // If only one account has access to this email address, select it
-    // as the identity of the user.
-    //
-    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
-    if (a.size() == 1) {
-      u.setAccount(a.iterator().next());
-    }
-
-    return u;
-  }
-
   private List<PatchSetInfo.ParentInfo> toParentInfos(RevCommit[] parents, RevWalk walk)
       throws IOException, MissingObjectException {
     List<PatchSetInfo.ParentInfo> pInfos = new ArrayList<>(parents.length);
     for (RevCommit parent : parents) {
       walk.parseBody(parent);
-      RevId rev = new RevId(parent.getId().name());
       String msg = parent.getShortMessage();
-      pInfos.add(new PatchSetInfo.ParentInfo(rev, msg));
+      pInfos.add(new PatchSetInfo.ParentInfo(parent, msg));
     }
     return pInfos;
   }
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index 011185d..cc0a5e4 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
@@ -62,7 +63,7 @@
             RevCommit p = c.getParent(0);
             rw.parseBody(p);
             b.append("Parent:     ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -73,7 +74,7 @@
             RevCommit p = c.getParent(i);
             rw.parseBody(p);
             b.append(i == 0 ? "Merge Of:   " : "            ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -106,7 +107,7 @@
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uniterestingParent)) {
             b.append("* ");
-            b.append(reader.abbreviate(commit, 8).name());
+            b.append(abbreviateName(commit, reader));
             b.append(" ");
             b.append(commit.getShortMessage());
             b.append("\n");
@@ -116,6 +117,10 @@
     }
   }
 
+  private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
+    return ObjectIds.abbreviateName(p, 8, reader);
+  }
+
   private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
     if (person != null) {
       b.append(field).append(":    ");
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index ee362002..07cb50d 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -22,12 +22,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index b23c85f..6142bc0 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -22,13 +22,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index a5174e1..f6a5335 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
+import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
 
@@ -31,17 +30,17 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupCache;
@@ -50,6 +49,8 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -67,7 +68,6 @@
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.SymbolicRef;
 
 class DefaultRefFilter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -89,7 +89,7 @@
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
 
-  private Map<Change.Id, Branch.NameKey> visibleChanges;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
 
   @Inject
   DefaultRefFilter(
@@ -130,69 +130,100 @@
   /** Filters given refs and tags by visibility. */
   Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
+    logger.atFinest().log(
+        "Filter refs for repository %s by visibility (options = %s, refs = %s)",
+        projectState.getNameKey(), opts, refs);
+    logger.atFinest().log("Calling user: %s", user.getLoggableName());
+    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log(
+        "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
+        skipFullRefEvaluationIfAllRefsAreVisible);
+    logger.atFinest().log(
+        "Project state %s permits read = %s",
+        projectState.getProject().getState(), projectState.statePermitsRead());
+
     // See if we can get away with a single, cheap ref evaluation.
     if (refs.size() == 1) {
       String refName = Iterables.getOnlyElement(refs.values()).getName();
       if (opts.filterMeta() && isMetadata(refName)) {
+        logger.atFinest().log("Filter out metadata ref %s", refName);
         return ImmutableMap.of();
       }
       if (RefNames.isRefsChanges(refName)) {
-        return canSeeSingleChangeRef(refName) ? refs : ImmutableMap.of();
+        boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
+        if (isChangeRefVisisble) {
+          logger.atFinest().log("Change ref %s is visible", refName);
+          return refs;
+        }
+        logger.atFinest().log("Filter out non-visible change ref %s", refName);
+        return ImmutableMap.of();
       }
     }
 
     // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
-    // we have to investigate (deferred tags) separately then perform a reachability check starting
+    // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
     Result initialRefFilter = filterRefs(refs, repo, opts);
     Map<String, Ref> visibleRefs = initialRefFilter.visibleRefs();
     if (!initialRefFilter.deferredTags().isEmpty()) {
-      Result allVisibleBranches = filterRefs(getTaggableRefsMap(repo), repo, opts);
-      checkState(
-          allVisibleBranches.deferredTags().isEmpty(),
-          "unexpected tags found when filtering refs/heads/* " + allVisibleBranches.deferredTags());
+      try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
+        Result allVisibleBranches = filterRefs(getTaggableRefsMap(repo), repo, opts);
+        checkState(
+            allVisibleBranches.deferredTags().isEmpty(),
+            "unexpected tags found when filtering refs/heads/* "
+                + allVisibleBranches.deferredTags());
 
-      TagMatcher tags =
-          tagCache
-              .get(projectState.getNameKey())
-              .matcher(tagCache, repo, allVisibleBranches.visibleRefs().values());
-      for (Ref tag : initialRefFilter.deferredTags()) {
-        try {
-          if (tags.isReachable(tag)) {
-            visibleRefs.put(tag.getName(), tag);
+        TagMatcher tags =
+            tagCache
+                .get(projectState.getNameKey())
+                .matcher(tagCache, repo, allVisibleBranches.visibleRefs().values());
+        for (Ref tag : initialRefFilter.deferredTags()) {
+          try {
+            if (tags.isReachable(tag)) {
+              logger.atFinest().log("Include reachable tag %s", tag.getName());
+              visibleRefs.put(tag.getName(), tag);
+            } else {
+              logger.atFinest().log("Filter out non-reachable tag %s", tag.getName());
+            }
+          } catch (IOException e) {
+            throw new PermissionBackendException(e);
           }
-        } catch (IOException e) {
-          throw new PermissionBackendException(e);
         }
       }
     }
+
+    logger.atFinest().log("visible refs = %s", visibleRefs);
     return visibleRefs;
   }
 
   /**
    * Filters refs by visibility. Returns tags where visibility can't be trivially computed
-   * separately for later ref-walk-based visibility computation. Tags where visibility is trivial to
+   * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
    * compute will be returned as part of {@link Result#visibleRefs()}.
    */
   Result filterRefs(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
-    if (projectState.isAllUsers()) {
-      refs = addUsersSelfSymref(refs);
-    }
+    logger.atFinest().log("Filter refs (refs = %s)", refs);
 
     // TODO(hiesel): Remove when optimization is done.
     boolean hasReadOnRefsStar =
         checkProjectPermission(permissionBackendForProject, ProjectPermission.READ);
+    logger.atFinest().log("User has READ on refs/* = %s", hasReadOnRefsStar);
     if (skipFullRefEvaluationIfAllRefsAreVisible && !projectState.isAllUsers()) {
       if (projectState.statePermitsRead() && hasReadOnRefsStar) {
         skipFilterCount.increment();
+        logger.atFinest().log(
+            "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
         return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         skipFilterCount.increment();
-        return new AutoValue_DefaultRefFilter_Result(
-            fastHideRefsMetaConfig(refs), ImmutableList.of());
+        refs = fastHideRefsMetaConfig(refs);
+        logger.atFinest().log(
+            "Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
+        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
       }
     }
+    logger.atFinest().log("Doing full ref filtering");
     fullFilterCount.increment();
 
     boolean viewMetadata;
@@ -205,36 +236,52 @@
       isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
       identifiedUser = user.asIdentifiedUser();
       userId = identifiedUser.getAccountId();
+      logger.atFinest().log(
+          "Account = %d; can view metadata = %s; is admin = %s",
+          userId.get(), viewMetadata, isAdmin);
     } else {
+      logger.atFinest().log("User is anonymous");
       viewMetadata = false;
       isAdmin = false;
       userId = null;
       identifiedUser = null;
     }
 
-    Map<String, Ref> result = new HashMap<>();
+    Map<String, Ref> resultRefs = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
     for (Ref ref : refs.values()) {
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
       AccountGroup.UUID accountGroupUuid;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (opts.filterMeta() && isMetadata(name))) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE)) {
+        continue;
+      } else if (opts.filterMeta() && isMetadata(name)) {
+        logger.atFinest().log("Filter out metadata ref %s", name);
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
         if (viewMetadata || visibleEdit(repo, name)) {
-          result.put(name, ref);
+          logger.atFinest().log("Include edit ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out edit ref %s", name);
         }
       } else if ((changeId = Change.Id.fromRef(name)) != null) {
         // Change ref is visible only if the change is visible.
         if (viewMetadata || visible(repo, changeId)) {
-          result.put(name, ref);
+          logger.atFinest().log("Include change ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out change ref %s", name);
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
         // Account ref is visible only to the corresponding account.
         if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
-          result.put(name, ref);
+          logger.atFinest().log("Include user ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out user ref %s", name);
         }
       } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
         // Group ref is visible only to the corresponding owner group.
@@ -243,7 +290,10 @@
             || (group != null
                 && isGroupOwner(group, identifiedUser, isAdmin)
                 && canReadRef(name))) {
-          result.put(name, ref);
+          logger.atFinest().log("Include group ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out group ref %s", name);
         }
       } else if (isTag(ref)) {
         if (hasReadOnRefsStar) {
@@ -256,39 +306,56 @@
           // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
           // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
           // is a negligible risk.
-          result.put(name, ref);
+          logger.atFinest().log("Include tag ref %s because user has read on refs/*", name);
+          resultRefs.put(name, ref);
         } else {
           // If its a tag, consider it later.
           if (ref.getObjectId() != null) {
+            logger.atFinest().log("Defer tag ref %s", name);
             deferredTags.add(ref);
+          } else {
+            logger.atFinest().log("Filter out tag ref %s that is not a tag", name);
           }
         }
       } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
         // Sequences are internal database implementation details.
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include sequence ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out sequence ref %s", name);
         }
       } else if (projectState.isAllUsers()
           && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
         // The notes branches with the external IDs / group names must not be exposed to normal
         // users.
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include external IDs branch %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out external IDs branch %s", name);
         }
       } else if (canReadRef(ref.getLeaf().getName())) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
-        result.put(name, ref);
+        logger.atFinest().log(
+            "Include ref %s because its leaf %s is readable", name, ref.getLeaf().getName());
+        resultRefs.put(name, ref);
       } else if (isRefsUsersSelf(ref)) {
         // viewMetadata allows to see all account refs, hence refs/users/self should be included as
         // well
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include ref %s", REFS_USERS_SELF);
+          resultRefs.put(name, ref);
         }
+      } else {
+        logger.atFinest().log("Filter out ref %s", name);
       }
     }
-    return new AutoValue_DefaultRefFilter_Result(result, deferredTags);
+    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    logger.atFinest().log("Result of ref filtering = %s", result);
+    return result;
   }
 
   /**
@@ -323,18 +390,6 @@
     return refs;
   }
 
-  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
-    if (user.isIdentifiedUser()) {
-      Ref r = refs.get(RefNames.refsUsers(user.getAccountId()));
-      if (r != null) {
-        SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
-        refs = new HashMap<>(refs);
-        refs.put(s.getName(), s);
-      }
-    }
-    return refs;
-  }
-
   private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
     if (visibleChanges == null) {
       if (changeCache == null) {
@@ -342,44 +397,51 @@
       } else {
         visibleChanges = visibleChangesBySearch();
       }
+      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
     }
     return visibleChanges.containsKey(changeId);
   }
 
   private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
-    // Initialize if it wasn't yet
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
     if (id == null) {
+      logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
       return false;
     }
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
         && visible(repo, id)) {
+      logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
+
+    // Initialize visibleChanges if it wasn't initialized yet.
+    if (visibleChanges == null) {
+      visible(repo, id);
+    }
     if (visibleChanges.containsKey(id)) {
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).get())
+            .ref(visibleChanges.get(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
+        logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
       } catch (AuthException e) {
+        logger.atFinest().log("Foreign change edit ref is not visible: %s", name);
         return false;
       }
     }
+
+    logger.atFinest().log("Change %d of change edit ref %s is not visible", id.get(), name);
     return false;
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch()
-      throws PermissionBackendException {
+  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
     Project.NameKey project = projectState.getNameKey();
     try {
-      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
+      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
         ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
         if (!projectState.statePermitsRead()) {
@@ -400,7 +462,7 @@
     }
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo)
+  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
       throws PermissionBackendException {
     Project.NameKey p = projectState.getNameKey();
     ImmutableList<ChangeNotesResult> changes;
@@ -412,7 +474,7 @@
       return Collections.emptyMap();
     }
 
-    Map<Change.Id, Branch.NameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
+    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
     for (ChangeNotesResult notesResult : changes) {
       ChangeNotes notes = toNotes(notesResult);
       if (notes != null) {
@@ -444,7 +506,9 @@
   }
 
   private boolean isMetadata(String name) {
-    return RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
+    boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
+    logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
+    return isMetaData;
   }
 
   private static boolean isTag(Ref ref) {
@@ -480,8 +544,10 @@
     requireNonNull(group);
 
     // Keep this logic in sync with GroupControl#isOwner().
-    return isAdmin
-        || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+    boolean isGroupOwner =
+        isAdmin || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+    logger.atFinest().log("User is owner of group %s = %s", group.getGroupUUID(), isGroupOwner);
+    return isGroupOwner;
   }
 
   /**
@@ -499,7 +565,7 @@
     // even if the change is not part of the set of most recent changes that
     // SearchingChangeCacheImpl returns.
     Change.Id cId = Change.Id.fromRef(refName);
-    checkNotNull(cId, "invalid change id for ref %s", refName);
+    requireNonNull(cId, () -> String.format("invalid change id for ref %s", refName));
     ChangeNotes notes;
     try {
       notes = changeNotesFactory.create(projectState.getNameKey(), cId);
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 5c7ee0d..0800d6b 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.permissions;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 7960c65..e0c5927 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -23,15 +23,15 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -157,8 +157,8 @@
     public abstract ForProject project(Project.NameKey project);
 
     /** Returns an instance scoped for the {@code ref}, and its parent project. */
-    public ForRef ref(Branch.NameKey ref) {
-      return project(ref.getParentKey()).ref(ref.get());
+    public ForRef ref(BranchNameKey ref) {
+      return project(ref.project()).ref(ref.branch());
     }
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
@@ -280,7 +280,7 @@
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeData cd) {
       try {
-        return ref(cd.change().getDest().get()).change(cd);
+        return ref(cd.change().getDest().branch()).change(cd);
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
@@ -288,7 +288,7 @@
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).change(notes);
+      return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
     /**
@@ -297,7 +297,7 @@
      * stale data from the index is acceptable.
      */
     public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+      return ref(notes.getChange().getDest().branch()).indexedChange(cd, notes);
     }
 
     /** Verify scoped user can {@code perm}, throwing if denied. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index 8ecb274..1f0370b 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -27,12 +27,12 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.project.RefPatternMatcher.ExpandParameters;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index dfc3339..cc3b666 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -17,23 +17,23 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.common.data.AccessSection.ALL;
 import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+import static com.google.gerrit.entities.RefNames.REFS_TAGS;
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.GitReceivePackGroups;
@@ -111,8 +111,8 @@
     return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
 
-  RefControl controlForRef(Branch.NameKey ref) {
-    return controlForRef(ref.get());
+  RefControl controlForRef(BranchNameKey ref) {
+    return controlForRef(ref.branch());
   }
 
   public RefControl controlForRef(String refName) {
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index 653303a..fc31e96 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GerritPermission;
-import com.google.gerrit.reviewdb.client.RefNames;
 
 public enum ProjectPermission implements CoreOrPluginProjectPermission {
   /**
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 9a2ecdd..06fe471 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -395,7 +395,7 @@
           withForce,
           projectControl.getProject().getName(),
           refName,
-          callerFinder.findCaller());
+          callerFinder.findCallerLazy());
       return false;
     }
 
@@ -408,7 +408,7 @@
             withForce,
             projectControl.getProject().getName(),
             refName,
-            callerFinder.findCaller());
+            callerFinder.findCallerLazy());
         return true;
       }
     }
@@ -420,7 +420,7 @@
         withForce,
         projectControl.getProject().getName(),
         refName,
-        callerFinder.findCaller());
+        callerFinder.findCallerLazy());
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/plugincontext/PluginContext.java b/java/com/google/gerrit/server/plugincontext/PluginContext.java
index 70b23e3..90d56c8 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginContext.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -118,25 +119,32 @@
 
     @Inject
     PluginMetrics(MetricMaker metricMaker) {
+      Field<String> pluginNameField =
+          Field.ofString("plugin_name", Metadata.Builder::pluginName).build();
+      Field<String> classNameField =
+          Field.ofString("class_name", Metadata.Builder::className).build();
+      Field<String> exportValueField =
+          Field.ofString("export_value", Metadata.Builder::exportValue).build();
+
       this.latency =
           metricMaker.newTimer(
               "plugin/latency",
               new Description("Latency for plugin invocation")
                   .setCumulative()
                   .setUnit(Units.MILLISECONDS),
-              Field.ofString("plugin_name"),
-              Field.ofString("class_name"),
-              Field.ofString("export_name"));
+              pluginNameField,
+              classNameField,
+              exportValueField);
       this.errorCount =
           metricMaker.newCounter(
               "plugin/error_count",
               new Description("Number of plugin errors").setCumulative().setUnit("errors"),
-              Field.ofString("plugin_name"),
-              Field.ofString("class_name"),
-              Field.ofString("export_name"));
+              pluginNameField,
+              classNameField,
+              exportValueField);
     }
 
-    Timer3.Context startLatency(Extension<?> extension) {
+    Timer3.Context<String, String, String> startLatency(Extension<?> extension) {
       return latency.start(
           extension.getPluginName(),
           extension.get().getClass().getName(),
@@ -194,7 +202,7 @@
       return;
     }
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
     } catch (Throwable e) {
       pluginMetrics.incrementErrorCount(extension);
@@ -223,7 +231,7 @@
     }
 
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
     } catch (Throwable e) {
       pluginMetrics.incrementErrorCount(extension);
@@ -257,14 +265,14 @@
     }
 
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
     } catch (Throwable e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
       Throwables.throwIfUnchecked(e);
       pluginMetrics.incrementErrorCount(extension);
       logger.atWarning().withCause(e).log(
-          "Failure in %s of plugin invoke%s", extensionImpl.getClass(), extension.getPluginName());
+          "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
     }
   }
 
@@ -294,7 +302,7 @@
     }
 
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
     } catch (Throwable e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
@@ -320,7 +328,7 @@
       Extension<T> extension,
       ExtensionImplFunction<T, R> extensionImplFunction) {
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       return extensionImplFunction.call(extension.get());
     }
   }
@@ -345,7 +353,7 @@
       Class<X> exceptionClass)
       throws X {
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       try {
         return checkedExtensionImplFunction.call(extension.get());
       } catch (Exception e) {
@@ -374,7 +382,7 @@
       Extension<T> extension,
       ExtensionFunction<Extension<T>, R> extensionFunction) {
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       return extensionFunction.call(extension);
     }
   }
@@ -400,7 +408,7 @@
       Class<X> exceptionClass)
       throws X {
     try (TraceContext traceContext = newTrace(extension);
-        Timer3.Context ctx = pluginMetrics.startLatency(extension)) {
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       try {
         return checkedExtensionFunction.call(extension);
       } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 62eb993..8adae52 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -17,6 +17,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -30,15 +32,20 @@
 
   private final PluginLoader loader;
   private final PermissionBackend permissionBackend;
+  private final MandatoryPluginsCollection mandatoryPluginsCollection;
 
   @Inject
-  DisablePlugin(PluginLoader loader, PermissionBackend permissionBackend) {
+  DisablePlugin(
+      PluginLoader loader,
+      PermissionBackend permissionBackend,
+      MandatoryPluginsCollection mandatoryPluginsCollection) {
     this.loader = loader;
     this.permissionBackend = permissionBackend;
+    this.mandatoryPluginsCollection = mandatoryPluginsCollection;
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input) throws RestApiException {
     try {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     } catch (PermissionBackendException e) {
@@ -46,7 +53,10 @@
     }
     loader.checkRemoteAdminEnabled();
     String name = resource.getName();
+    if (mandatoryPluginsCollection.contains(name)) {
+      throw new MethodNotAllowedException("Plugin " + name + " is mandatory");
+    }
     loader.disablePlugins(ImmutableSet.of(name));
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/EnablePlugin.java b/java/com/google/gerrit/server/plugins/EnablePlugin.java
index 569bc39..b45aaf1f 100644
--- a/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Inject;
@@ -39,7 +40,7 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input) throws RestApiException {
     loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     try {
@@ -52,6 +53,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/GetStatus.java b/java/com/google/gerrit/server/plugins/GetStatus.java
index cbd864a..5fcc96a 100644
--- a/java/com/google/gerrit/server/plugins/GetStatus.java
+++ b/java/com/google/gerrit/server/plugins/GetStatus.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetStatus implements RestReadView<PluginResource> {
   @Override
-  public PluginInfo apply(PluginResource resource) {
-    return ListPlugins.toPluginInfo(resource.getPlugin());
+  public Response<PluginInfo> apply(PluginResource resource) {
+    return Response.ok(ListPlugins.toPluginInfo(resource.getPlugin()));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/ListPlugins.java b/java/com/google/gerrit/server/plugins/ListPlugins.java
index 84e63d0..465d041 100644
--- a/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
@@ -111,7 +112,8 @@
   }
 
   @Override
-  public SortedMap<String, PluginInfo> apply(TopLevelResource resource) throws BadRequestException {
+  public Response<SortedMap<String, PluginInfo>> apply(TopLevelResource resource)
+      throws BadRequestException {
     Stream<Plugin> s = Streams.stream(pluginLoader.getPlugins(all));
     if (matchPrefix != null) {
       checkMatchOptions(matchSubstring == null && matchRegex == null);
@@ -132,7 +134,7 @@
     if (limit > 0) {
       s = s.limit(limit);
     }
-    return new TreeMap<>(s.collect(toMap(Plugin::getName, ListPlugins::toPluginInfo)));
+    return Response.ok(new TreeMap<>(s.collect(toMap(Plugin::getName, ListPlugins::toPluginInfo))));
   }
 
   private void checkMatchOptions(boolean cond) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java b/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java
new file mode 100644
index 0000000..70a0fff
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class MandatoryPluginsCollection {
+  private final CopyOnWriteArraySet<String> members;
+
+  @Inject
+  MandatoryPluginsCollection(@GerritServerConfig Config cfg) {
+    members = Sets.newCopyOnWriteArraySet();
+    members.addAll(Arrays.asList(cfg.getStringList("plugins", null, "mandatory")));
+  }
+
+  public boolean contains(String name) {
+    return members.contains(name);
+  }
+
+  public Set<String> asSet() {
+    return ImmutableSet.copyOf(members);
+  }
+
+  @VisibleForTesting
+  public void add(String name) {
+    members.add(name);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java b/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java
new file mode 100644
index 0000000..1c23550
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import java.util.Collection;
+
+/** Raised when one or more mandatory plugins are missing. */
+public class MissingMandatoryPluginsException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public MissingMandatoryPluginsException(Collection<String> pluginNames) {
+    super(getMessage(pluginNames));
+  }
+
+  private static String getMessage(Collection<String> pluginNames) {
+    return String.format("Cannot find or load the following mandatory plugins: %s", pluginNames);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 9279f0fe..c4f4a1f 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -52,6 +52,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -87,6 +88,7 @@
   private final Provider<String> urlProvider;
   private final PersistentCacheFactory persistentCacheFactory;
   private final boolean remoteAdmin;
+  private final MandatoryPluginsCollection mandatoryPlugins;
   private final UniversalServerPluginProvider serverPluginFactory;
   private final GerritRuntime gerritRuntime;
 
@@ -101,6 +103,7 @@
       @CanonicalWebUrl Provider<String> provider,
       PersistentCacheFactory cacheFactory,
       UniversalServerPluginProvider pluginFactory,
+      MandatoryPluginsCollection mpc,
       GerritRuntime gerritRuntime) {
     pluginsDir = sitePaths.plugins_dir;
     dataDir = sitePaths.data_dir;
@@ -114,6 +117,7 @@
     serverPluginFactory = pluginFactory;
 
     remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
+    mandatoryPlugins = mpc;
     this.gerritRuntime = gerritRuntime;
 
     long checkFrequency =
@@ -226,6 +230,11 @@
           continue;
         }
 
+        if (mandatoryPlugins.contains(name)) {
+          logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
+          continue;
+        }
+
         logger.atInfo().log("Disabling plugin %s", active.getName());
         Path off =
             active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
@@ -381,50 +390,57 @@
   }
 
   public synchronized void rescan() {
+    Set<String> loadedPlugins = new HashSet<>();
     SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
-    if (pluginsFiles.isEmpty()) {
-      return;
+
+    if (!pluginsFiles.isEmpty()) {
+      syncDisabledPlugins(pluginsFiles);
+
+      Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
+      for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
+        String name = entry.getKey();
+        Path path = entry.getValue();
+        String fileName = path.getFileName().toString();
+        if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
+          logger.atWarning().log(
+              "No Plugin provider was found that handles this file format: %s", fileName);
+          continue;
+        }
+
+        FileSnapshot brokenTime = broken.get(name);
+        if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
+          continue;
+        }
+
+        Plugin active = running.get(name);
+        if (active != null && !active.isModified(path)) {
+          loadedPlugins.add(name);
+          continue;
+        }
+
+        if (active != null) {
+          logger.atInfo().log("Reloading plugin %s", active.getName());
+        }
+
+        try {
+          Plugin loadedPlugin = runPlugin(name, path, active);
+          if (!loadedPlugin.isDisabled()) {
+            loadedPlugins.add(name);
+            logger.atInfo().log(
+                "%s plugin %s, version %s",
+                active == null ? "Loaded" : "Reloaded",
+                loadedPlugin.getName(),
+                loadedPlugin.getVersion());
+          }
+        } catch (PluginInstallException e) {
+          logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
+        }
+      }
     }
 
-    syncDisabledPlugins(pluginsFiles);
-
-    Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
-    for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
-      String name = entry.getKey();
-      Path path = entry.getValue();
-      String fileName = path.getFileName().toString();
-      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
-        logger.atWarning().log(
-            "No Plugin provider was found that handles this file format: %s", fileName);
-        continue;
-      }
-
-      FileSnapshot brokenTime = broken.get(name);
-      if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
-        continue;
-      }
-
-      Plugin active = running.get(name);
-      if (active != null && !active.isModified(path)) {
-        continue;
-      }
-
-      if (active != null) {
-        logger.atInfo().log("Reloading plugin %s", active.getName());
-      }
-
-      try {
-        Plugin loadedPlugin = runPlugin(name, path, active);
-        if (!loadedPlugin.isDisabled()) {
-          logger.atInfo().log(
-              "%s plugin %s, version %s",
-              active == null ? "Loaded" : "Reloaded",
-              loadedPlugin.getName(),
-              loadedPlugin.getVersion());
-        }
-      } catch (PluginInstallException e) {
-        logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
-      }
+    Set<String> missingMandatory = Sets.difference(mandatoryPlugins.asSet(), loadedPlugins);
+    if (!missingMandatory.isEmpty()) {
+      throw new MissingMandatoryPluginsException(missingMandatory);
     }
 
     cleanInBackground();
@@ -471,6 +487,12 @@
       throws PluginInstallException {
     FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
     try {
+      boolean restartRequired = oldPlugin != null && !oldPlugin.canReload();
+      if (restartRequired && mandatoryPlugins.contains(name)) {
+        logger.atWarning().log("Restarting mandatory plugin %s not allowed", name);
+        return oldPlugin;
+      }
+
       Plugin newPlugin = loadPlugin(name, plugin, snapshot);
       if (newPlugin.getCleanupHandle() != null) {
         cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
diff --git a/java/com/google/gerrit/server/plugins/PluginModule.java b/java/com/google/gerrit/server/plugins/PluginModule.java
index 6bc37bd..71186e5 100644
--- a/java/com/google/gerrit/server/plugins/PluginModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginModule.java
@@ -34,6 +34,7 @@
     bind(PluginLoader.class);
     bind(CopyConfigModule.class);
     listener().to(PluginLoader.class);
+    bind(MandatoryPluginsCollection.class);
 
     DynamicSet.setOf(binder(), ServerPluginProvider.class);
     DynamicSet.bind(binder(), ServerPluginProvider.class).to(JarPluginProvider.class);
diff --git a/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
index 1134f50..490c4aa 100644
--- a/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -38,7 +39,8 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws ResourceConflictException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input)
+      throws ResourceConflictException {
     String name = resource.getName();
     try {
       loader.reload(ImmutableList.of(name));
@@ -52,6 +54,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/project/AccessControlModule.java b/java/com/google/gerrit/server/project/AccessControlModule.java
index 6d77267..89ab8ee 100644
--- a/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -18,8 +18,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.AdministrateServerGroupsProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index dc9cf9c..ae9828a 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -16,11 +16,11 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo.InheritedBooleanInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import java.util.Arrays;
 import java.util.HashSet;
 
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index 622b1dd..a8936ac 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.Ref;
@@ -33,8 +33,8 @@
     this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
   }
 
-  public Branch.NameKey getBranchKey() {
-    return new Branch.NameKey(getNameKey(), refName);
+  public BranchNameKey getBranchKey() {
+    return BranchNameKey.create(getNameKey(), refName);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/ChildProjects.java b/java/com/google/gerrit/server/project/ChildProjects.java
index ce9992e..2069a48 100644
--- a/java/com/google/gerrit/server/project/ChildProjects.java
+++ b/java/com/google/gerrit/server/project/ChildProjects.java
@@ -18,8 +18,8 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Multimap;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index b33fcb5..a2de4ef 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -20,15 +20,15 @@
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.UrlFormatter;
@@ -117,7 +117,7 @@
         if ((rule.getAction() == Action.ALLOW)
             && (rule.getGroup() != null)
             && (rule.getGroup().getUUID() != null)) {
-          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
+          groupIds.add(AccountGroup.uuid(rule.getGroup().getUUID().get()));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index df31c19..c1b7b86 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import java.util.List;
 
 public class CreateProjectArgs {
@@ -61,7 +61,7 @@
   }
 
   public void setProjectName(String n) {
-    projectName = n != null ? new Project.NameKey(n) : null;
+    projectName = n != null ? Project.nameKey(n) : null;
   }
 
   public void setProjectName(Project.NameKey n) {
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index 7a7598b..abbd9f6 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -65,15 +65,12 @@
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void checkCreateRef(
-      Provider<? extends CurrentUser> user,
-      Repository repo,
-      Branch.NameKey branch,
-      RevObject object)
+      Provider<? extends CurrentUser> user, Repository repo, BranchNameKey branch, RevObject object)
       throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
           ResourceConflictException {
-    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
+    ProjectState ps = projectCache.checkedGet(branch.project());
     if (ps == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     ps.checkStatePermitsWrite();
 
@@ -86,8 +83,7 @@
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(
-            "RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name());
+        logger.atSevere().withCause(e).log("RevWalk(%s) parsing %s:", branch.project(), tag.name());
         throw e;
       }
 
diff --git a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
index 3fb5d2a..000fb09 100644
--- a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.util.concurrent.Striped;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 import java.util.concurrent.locks.Lock;
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index fdb8740..fe59012 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
 import java.io.IOException;
@@ -48,7 +48,7 @@
         logger.atWarning().log("null field in group list for %s:\n%s", project, text);
         continue;
       }
-      AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
+      AccountGroup.UUID uuid = AccountGroup.uuid(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
 
diff --git a/java/com/google/gerrit/server/project/NoSuchChangeException.java b/java/com/google/gerrit/server/project/NoSuchChangeException.java
index 6f65659..93cf03a 100644
--- a/java/com/google/gerrit/server/project/NoSuchChangeException.java
+++ b/java/com/google/gerrit/server/project/NoSuchChangeException.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Change;
 
 /** Indicates the change does not exist. */
 public class NoSuchChangeException extends StorageException {
diff --git a/java/com/google/gerrit/server/project/NoSuchProjectException.java b/java/com/google/gerrit/server/project/NoSuchProjectException.java
index 23d8d80..d4a8a5c 100644
--- a/java/com/google/gerrit/server/project/NoSuchProjectException.java
+++ b/java/com/google/gerrit/server/project/NoSuchProjectException.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 
 /** Indicates the project does not exist. */
 public class NoSuchProjectException extends Exception {
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 17250fa..0baaa11e 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import java.io.IOException;
 import java.util.Set;
 
@@ -35,7 +35,7 @@
    * @param projectName name of the project.
    * @return the cached data; null if no such project exists, projectName is null or an error
    *     occurred.
-   * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
+   * @see #checkedGet(com.google.gerrit.entities.Project.NameKey)
    */
   ProjectState get(@Nullable Project.NameKey projectName);
 
@@ -57,7 +57,7 @@
    *     errors.
    * @return the cached data or null when strict = false
    */
-  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
+  ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index bd26a8f..e88bfc6 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,18 +24,19 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
@@ -262,8 +263,8 @@
 
   @Override
   public ImmutableSortedSet<Project.NameKey> byName(String pfx) {
-    Project.NameKey start = new Project.NameKey(pfx);
-    Project.NameKey end = new Project.NameKey(pfx + Character.MAX_VALUE);
+    Project.NameKey start = Project.nameKey(pfx);
+    Project.NameKey end = Project.nameKey(pfx + Character.MAX_VALUE);
     try {
       // Right endpoint is exclusive, but U+FFFF is a non-character so no project ends with it.
       return list.get(ListKey.ALL).subSet(start, end);
@@ -293,9 +294,11 @@
 
     @Override
     public ProjectState load(String projectName) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading project %s", projectName)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading project", Metadata.builder().projectName(projectName).build())) {
         long now = clock.read();
-        Project.NameKey key = new Project.NameKey(projectName);
+        Project.NameKey key = Project.nameKey(projectName);
         try (Repository git = mgr.openRepository(key)) {
           ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 10cf2de..d1f31a3 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index a7133bb..44d9d98 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.common.data.Permission.isPermission;
-import static com.google.gerrit.reviewdb.client.Project.DEFAULT_SUBMIT_TYPE;
+import static com.google.gerrit.entities.Project.DEFAULT_SUBMIT_TYPE;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
 import static java.util.stream.Collectors.toList;
 
@@ -26,6 +26,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
@@ -42,15 +43,15 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -100,6 +101,7 @@
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
+  public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
@@ -337,6 +339,10 @@
     return as;
   }
 
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(accessSections.keySet());
+  }
+
   public Collection<AccessSection> getAccessSections() {
     return sort(accessSections.values());
   }
@@ -349,7 +355,7 @@
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (SubscribeSection s : subscribeSections.values()) {
       if (s.appliesTo(branch)) {
@@ -717,7 +723,7 @@
         if (groupName != null) {
           GroupReference ref = groupsByName.get(groupName);
           if (ref == null) {
-            ref = new GroupReference(null, groupName);
+            ref = new GroupReference(groupName);
             groupsByName.put(ref.getName(), ref);
           }
           if (ref.getUUID() != null) {
@@ -963,6 +969,8 @@
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setIgnoreSelfApproval(
           rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
+      label.setCopyAnyScore(
+          rc.getBoolean(LABEL, name, KEY_COPY_ANY_SCORE, LabelType.DEF_COPY_ANY_SCORE));
       label.setCopyMinScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
@@ -1042,7 +1050,7 @@
     subscribeSections = new HashMap<>();
     try {
       for (String projectName : subsections) {
-        Project.NameKey p = new Project.NameKey(projectName);
+        Project.NameKey p = Project.nameKey(projectName);
         SubscribeSection ss = new SubscribeSection(p);
         for (String s :
             rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
@@ -1194,6 +1202,7 @@
   }
 
   private void saveAccountsSection(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    unsetSection(rc, ACCOUNTS);
     if (accountsSection != null) {
       rc.setStringList(
           ACCOUNTS,
@@ -1204,6 +1213,7 @@
   }
 
   private void saveCommentLinkSections(Config rc) {
+    unsetSection(rc, COMMENTLINK);
     if (commentLinkSections != null) {
       for (CommentLinkInfoImpl cm : commentLinkSections.values()) {
         rc.setString(COMMENTLINK, cm.name, KEY_MATCH, cm.match);
@@ -1221,6 +1231,7 @@
   }
 
   private void saveContributorAgreements(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    unsetSection(rc, CONTRIBUTOR_AGREEMENT);
     for (ContributorAgreement ca : sort(contributorAgreements.values())) {
       set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_DESCRIPTION, ca.getDescription());
       set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AGREEMENT_URL, ca.getAgreementUrl());
@@ -1254,6 +1265,7 @@
   }
 
   private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    unsetSection(rc, NOTIFY);
     for (NotifyConfig nc : sort(notifySections.values())) {
       nc.getGroups().stream()
           .map(GroupReference::getUUID)
@@ -1308,6 +1320,7 @@
   }
 
   private void saveAccessSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    unsetSection(rc, CAPABILITY);
     AccessSection capability = accessSections.get(AccessSection.GLOBAL_CAPABILITIES);
     if (capability != null) {
       Set<String> have = new HashSet<>();
@@ -1390,9 +1403,7 @@
     List<String> existing = new ArrayList<>(rc.getSubsections(LABEL));
     if (!new ArrayList<>(labelSections.keySet()).equals(existing)) {
       // Order of sections changed, remove and rewrite them all.
-      for (String name : existing) {
-        rc.unsetSection(LABEL, name);
-      }
+      unsetSection(rc, LABEL);
     }
 
     Set<String> toUnset = new HashSet<>(existing);
@@ -1421,6 +1432,13 @@
           rc,
           LABEL,
           name,
+          KEY_COPY_ANY_SCORE,
+          label.isCopyAnyScore(),
+          LabelType.DEF_COPY_ANY_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_MIN_SCORE,
           label.isCopyMinScore(),
           LabelType.DEF_COPY_MIN_SCORE);
@@ -1488,11 +1506,7 @@
   }
 
   private void savePluginSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    List<String> existing = new ArrayList<>(rc.getSubsections(PLUGIN));
-    for (String name : existing) {
-      rc.unsetSection(PLUGIN, name);
-    }
-
+    unsetSection(rc, PLUGIN);
     for (Map.Entry<String, Config> e : pluginConfigs.entrySet()) {
       String plugin = e.getKey();
       Config pluginConfig = e.getValue();
@@ -1533,6 +1547,13 @@
     }
   }
 
+  private void unsetSection(Config rc, String sectionName) {
+    for (String subSectionName : rc.getSubsections(sectionName)) {
+      rc.unsetSection(sectionName, subSectionName);
+    }
+    rc.unsetSection(sectionName, null);
+  }
+
   private <E extends Enum<?>> E getEnum(
       Config rc, String section, String subsection, String name, E defaultValue) {
     try {
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 35ecd7c..c9eb73e 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -21,13 +21,13 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
diff --git a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index 27bde72..694c541 100644
--- a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Iterator;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2a93d3..cd67fbd 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -17,19 +17,19 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.LabelTypeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.HashMap;
-import java.util.List;
 
 @Singleton
 public class ProjectJson {
@@ -65,7 +65,7 @@
     info.description = Strings.emptyToNull(p.getDescription());
     info.state = p.getState();
     info.id = Url.encode(info.name);
-    List<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
+    ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 961d1fc..4e0261c 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Arrays;
diff --git a/java/com/google/gerrit/server/project/ProjectNameLockManager.java b/java/com/google/gerrit/server/project/ProjectNameLockManager.java
index 4666c32..72036a7 100644
--- a/java/com/google/gerrit/server/project/ProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/ProjectNameLockManager.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.util.concurrent.locks.Lock;
 
 public interface ProjectNameLockManager {
diff --git a/java/com/google/gerrit/server/project/ProjectResource.java b/java/com/google/gerrit/server/project/ProjectResource.java
index 22b7bd9..8802758 100644
--- a/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/java/com/google/gerrit/server/project/ProjectResource.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 0e3a940..4b879a1 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -28,6 +28,11 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -37,17 +42,13 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -69,7 +70,7 @@
 
 /**
  * Cached information on a project. Must not contain any data derived from parents other than it's
- * immediate parent's {@link com.google.gerrit.reviewdb.client.Project.NameKey}.
+ * immediate parent's {@link com.google.gerrit.entities.Project.NameKey}.
  */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -135,7 +136,7 @@
             new Description("Latency for access computations in ProjectState")
                 .setCumulative()
                 .setUnit(Units.NANOSECONDS),
-            Field.ofString("method"));
+            Field.ofString("method", Metadata.Builder::methodName).build());
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -354,14 +355,15 @@
    * cached. Callers should try to cache this result per-request as much as possible.
    */
   public List<SectionMatcher> getAllSections() {
-    try (Timer1.Context ignored = computationLatency.start("getAllSections")) {
+    try (Timer1.Context<String> ignored = computationLatency.start("getAllSections")) {
       if (isAllProjects) {
         return getLocalAccessSections();
       }
 
       List<SectionMatcher> all = new ArrayList<>();
       Iterable<ProjectState> tree = tree();
-      try (Timer1.Context ignored2 = computationLatency.start("getAllSections-parsing-only")) {
+      try (Timer1.Context<String> ignored2 =
+          computationLatency.start("getAllSections-parsing-only")) {
         for (ProjectState s : tree) {
           all.addAll(s.getLocalAccessSections());
         }
@@ -476,7 +478,7 @@
   }
 
   /** All available label types for this branch. */
-  public LabelTypes getLabelTypes(Branch.NameKey destination) {
+  public LabelTypes getLabelTypes(BranchNameKey destination) {
     List<LabelType> all = getLabelTypes().getLabelTypes();
 
     List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
@@ -537,7 +539,7 @@
     return null;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (ProjectState s : tree()) {
       ret.addAll(s.getConfig().getSubscribeSections(branch));
@@ -584,7 +586,7 @@
     return project;
   }
 
-  private boolean match(Branch.NameKey destination, String refPattern) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), null);
+  private boolean match(BranchNameKey destination, String refPattern) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.branch(), null);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 17fd69c..a3b4126 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -24,6 +24,10 @@
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.api.projects.CheckProjectInput;
@@ -38,9 +42,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -225,7 +226,7 @@
               // important thing for callers is that auto-closable changes are closed. Which of the
               // commits is used to auto-close a change if there are several candidates is of minor
               // importance and hence can be non-deterministic.
-              Change.Key changeKey = new Change.Key(changeId);
+              Change.Key changeKey = Change.key(changeId);
               if (!changeIdToMergedSha1.containsKey(changeKey)) {
                 changeIdToMergedSha1.put(changeKey, commitId);
               }
@@ -295,7 +296,7 @@
                 // Auto-close by commit
                 for (ObjectId patchSetSha1 :
                     autoCloseableChange.patchSets().stream()
-                        .map(ps -> ObjectId.fromString(ps.getRevision().get()))
+                        .map(PatchSet::commitId)
                         .collect(toSet())) {
                   if (mergedSha1s.contains(patchSetSha1)) {
                     autoCloseableChangesByBranch.add(
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 93cbf4b..b12f43b 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/project/RefFilter.java b/java/com/google/gerrit/server/project/RefFilter.java
index 76bafc0..cdabcbe 100644
--- a/java/com/google/gerrit/server/project/RefFilter.java
+++ b/java/com/google/gerrit/server/project/RefFilter.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.projects.RefInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 import java.util.List;
 import java.util.Locale;
+import java.util.stream.Stream;
 
 public class RefFilter<T extends RefInfo> {
   private final String prefix;
@@ -55,15 +57,17 @@
     return this;
   }
 
-  public List<T> filter(List<T> refs) throws BadRequestException {
+  public ImmutableList<T> filter(List<T> refs) throws BadRequestException {
     if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
       throw new BadRequestException("specify exactly one of m/r");
     }
-    FluentIterable<T> results = FluentIterable.from(refs);
+    Stream<T> results = refs.stream();
     if (!Strings.isNullOrEmpty(matchSubstring)) {
-      results = results.filter(new SubstringPredicate(matchSubstring));
+      String lowercaseSubstring = matchSubstring.toLowerCase(Locale.US);
+      results = results.filter(refInfo -> matchesSubstring(prefix, lowercaseSubstring, refInfo));
     } else if (!Strings.isNullOrEmpty(matchRegex)) {
-      results = results.filter(new RegexPredicate(matchRegex));
+      RunAutomaton a = parseRegex(matchRegex);
+      results = results.filter(refInfo -> matchesRegex(prefix, a, refInfo));
     }
     if (start > 0) {
       results = results.skip(start);
@@ -71,51 +75,39 @@
     if (limit > 0) {
       results = results.limit(limit);
     }
-    return results.toList();
+    return results.collect(toImmutableList());
   }
 
-  private class SubstringPredicate implements Predicate<T> {
-    private final String substring;
-
-    private SubstringPredicate(String substring) {
-      this.substring = substring.toLowerCase(Locale.US);
+  private static <T extends RefInfo> boolean matchesSubstring(
+      String prefix, String lowercaseSubstring, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
+    ref = ref.toLowerCase(Locale.US);
+    return ref.contains(lowercaseSubstring);
+  }
 
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
+  private static RunAutomaton parseRegex(String regex) throws BadRequestException {
+    if (regex.startsWith("^")) {
+      regex = regex.substring(1);
+      if (regex.endsWith("$") && !regex.endsWith("\\$")) {
+        regex = regex.substring(0, regex.length() - 1);
       }
-      ref = ref.toLowerCase(Locale.US);
-      return ref.contains(substring);
+    }
+    try {
+      return new RunAutomaton(new RegExp(regex).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
     }
   }
 
-  private class RegexPredicate implements Predicate<T> {
-    private final RunAutomaton a;
-
-    private RegexPredicate(String regex) throws BadRequestException {
-      if (regex.startsWith("^")) {
-        regex = regex.substring(1);
-        if (regex.endsWith("$") && !regex.endsWith("\\$")) {
-          regex = regex.substring(0, regex.length() - 1);
-        }
-      }
-      try {
-        a = new RunAutomaton(new RegExp(regex).toAutomaton());
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
+  private static <T extends RefInfo> boolean matchesRegex(
+      String prefix, RunAutomaton a, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
-
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
-      }
-      return a.run(ref);
-    }
+    return a.run(ref);
   }
 }
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index 4032524..b9076b3 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -22,8 +22,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import dk.brics.automaton.Automaton;
 import java.util.HashMap;
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index a9c964d..dc8cdc7 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import java.io.IOException;
 import java.util.Collections;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 0a5980c..9b297f9 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.validators.ValidationException;
@@ -43,7 +43,7 @@
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
-            new Project(new Project.NameKey(projectName)),
+            new Project(Project.nameKey(projectName)),
             user,
             RefOperationValidators.getCommand(update, operationType));
     try {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index eeb2a65..6bf3beb 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -47,7 +47,7 @@
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
       throws PermissionBackendException, AuthException {
-    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
+    checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
   }
 
   /**
@@ -108,7 +108,7 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().get()), RefPermission.WRITE_CONFIG)
+    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
         || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index a8ebd98..6de8eec 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 
 /**
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 1b1869c..cc7591c 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,8 +18,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.index.OnlineReindexMode;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -27,9 +31,9 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.Collectors;
 
 /**
@@ -44,6 +48,8 @@
   private final ProjectCache projectCache;
   private final PrologRule prologRule;
   private final PluginSetContext<SubmitRule> submitRules;
+  private final Timer0 submitRuleEvaluationLatency;
+  private final Timer0 submitTypeEvaluationLatency;
   private final SubmitRuleOptions opts;
 
   public interface Factory {
@@ -56,23 +62,36 @@
       ProjectCache projectCache,
       PrologRule prologRule,
       PluginSetContext<SubmitRule> submitRules,
+      MetricMaker metricMaker,
       @Assisted SubmitRuleOptions options) {
     this.projectCache = projectCache;
     this.prologRule = prologRule;
     this.submitRules = submitRules;
+    this.submitRuleEvaluationLatency =
+        metricMaker.newTimer(
+            "change/submit_rule_evaluation",
+            new Description("Latency for evaluating submit rules on a change.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+    this.submitTypeEvaluationLatency =
+        metricMaker.newTimer(
+            "change/submit_type_evaluation",
+            new Description("Latency for evaluating the submit type on a change.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
 
     this.opts = options;
   }
 
-  public static List<SubmitRecord> defaultRuleError() {
+  public static SubmitRecord defaultRuleError() {
     return createRuleError(DEFAULT_MSG);
   }
 
-  public static List<SubmitRecord> createRuleError(String err) {
+  public static SubmitRecord createRuleError(String err) {
     SubmitRecord rec = new SubmitRecord();
     rec.status = SubmitRecord.Status.RULE_ERROR;
     rec.errorMessage = err;
-    return Collections.singletonList(rec);
+    return rec;
   }
 
   public static SubmitTypeRecord defaultTypeError() {
@@ -87,46 +106,42 @@
    * @param cd ChangeData to evaluate
    */
   public List<SubmitRecord> evaluate(ChangeData cd) {
-    Change change;
-    ProjectState projectState;
-    try {
-      change = cd.change();
-      if (change == null) {
-        throw new StorageException("Change not found");
+    try (Timer0.Context ignored = submitRuleEvaluationLatency.start()) {
+      Change change;
+      ProjectState projectState;
+      try {
+        change = cd.change();
+        if (change == null) {
+          throw new StorageException("Change not found");
+        }
+
+        projectState = projectCache.get(cd.project());
+        if (projectState == null) {
+          throw new NoSuchProjectException(cd.project());
+        }
+      } catch (StorageException | NoSuchProjectException e) {
+        return Collections.singletonList(ruleError("Error looking up change " + cd.getId(), e));
       }
 
-      projectState = projectCache.get(cd.project());
-      if (projectState == null) {
-        throw new NoSuchProjectException(cd.project());
+      if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
+        SubmitRecord rec = new SubmitRecord();
+        rec.status = SubmitRecord.Status.CLOSED;
+        return Collections.singletonList(rec);
       }
-    } catch (StorageException | NoSuchProjectException e) {
-      return ruleError("Error looking up change " + cd.getId(), e);
-    }
 
-    if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
-      SubmitRecord rec = new SubmitRecord();
-      rec.status = SubmitRecord.Status.CLOSED;
-      return Collections.singletonList(rec);
+      // We evaluate all the plugin-defined evaluators,
+      // and then we collect the results in one list.
+      return Streams.stream(submitRules)
+          .map(c -> c.call(s -> s.evaluate(cd)))
+          .filter(Optional::isPresent)
+          .map(Optional::get)
+          .collect(Collectors.toList());
     }
-
-    // We evaluate all the plugin-defined evaluators,
-    // and then we collect the results in one list.
-    return Streams.stream(submitRules)
-        .map(c -> c.call(s -> s.evaluate(cd, opts)))
-        .flatMap(Collection::stream)
-        .collect(Collectors.toList());
   }
 
-  private List<SubmitRecord> ruleError(String err, Exception e) {
-    if (opts.logErrors()) {
-      if (e == null) {
-        logger.atSevere().log(err);
-      } else {
-        logger.atSevere().withCause(e).log(err);
-      }
-      return defaultRuleError();
-    }
-    return createRuleError(err);
+  private SubmitRecord ruleError(String err, Exception e) {
+    logger.atSevere().withCause(e).log(err);
+    return defaultRuleError();
   }
 
   /**
@@ -136,24 +151,23 @@
    * @param cd
    */
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
-    ProjectState projectState;
-    try {
-      projectState = projectCache.get(cd.project());
-      if (projectState == null) {
-        throw new NoSuchProjectException(cd.project());
+    try (Timer0.Context ignored = submitTypeEvaluationLatency.start()) {
+      ProjectState projectState;
+      try {
+        projectState = projectCache.get(cd.project());
+        if (projectState == null) {
+          throw new NoSuchProjectException(cd.project());
+        }
+      } catch (NoSuchProjectException e) {
+        return typeError("Error looking up change " + cd.getId(), e);
       }
-    } catch (NoSuchProjectException e) {
-      return typeError("Error looking up change " + cd.getId(), e);
-    }
 
-    return prologRule.getSubmitType(cd, opts);
+      return prologRule.getSubmitType(cd);
+    }
   }
 
   private SubmitTypeRecord typeError(String err, Exception e) {
-    if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
-      return defaultTypeError();
-    }
-    return SubmitTypeRecord.error(err);
+    logger.atSevere().withCause(e).log(err);
+    return defaultTypeError();
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index a4340b2..ad077c0 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
 
 /**
  * Stable identifier for options passed to a particular submit rule evaluator.
@@ -26,12 +25,7 @@
 @AutoValue
 public abstract class SubmitRuleOptions {
   private static final SubmitRuleOptions defaults =
-      new AutoValue_SubmitRuleOptions.Builder()
-          .allowClosed(false)
-          .skipFilters(false)
-          .logErrors(true)
-          .rule(null)
-          .build();
+      new AutoValue_SubmitRuleOptions.Builder().allowClosed(false).build();
 
   public static SubmitRuleOptions defaults() {
     return defaults;
@@ -43,25 +37,12 @@
 
   public abstract boolean allowClosed();
 
-  public abstract boolean skipFilters();
-
-  public abstract boolean logErrors();
-
-  @Nullable
-  public abstract String rule();
-
   public abstract Builder toBuilder();
 
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
 
-    public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
-
-    public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
-
-    public abstract SubmitRuleOptions.Builder logErrors(boolean logErrors);
-
     public abstract SubmitRuleOptions build();
   }
 }
diff --git a/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index d3dfdcd..fdc8b50 100644
--- a/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -16,7 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 968e3da..988a89f 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,9 +5,5 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server",
-    ],
+    deps = ["//java/com/google/gerrit/common:server"],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
new file mode 100644
index 0000000..6c2ddde
--- /dev/null
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project.testing;
+
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import java.util.Arrays;
+
+public class TestLabels {
+  public static LabelType codeReview() {
+    return label(
+        "Code-Review",
+        value(2, "Looks good to me, approved"),
+        value(1, "Looks good to me, but someone else must approve"),
+        value(0, "No score"),
+        value(-1, "I would prefer this is not merged as is"),
+        value(-2, "This shall not be merged"));
+  }
+
+  public static LabelType verified() {
+    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+  }
+
+  public static LabelType patchSetLock() {
+    LabelType label =
+        label("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    label.setFunction(LabelFunction.PATCH_SET_LOCK);
+    return label;
+  }
+
+  public static LabelValue value(int value, String text) {
+    return new LabelValue((short) value, text);
+  }
+
+  public static LabelType label(String name, LabelValue... values) {
+    return new LabelType(name, Arrays.asList(values));
+  }
+
+  private TestLabels() {}
+}
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
deleted file mode 100644
index 204fa7b..0000000
--- a/java/com/google/gerrit/server/project/testing/Util.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project.testing;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.ProjectConfig;
-import java.util.Arrays;
-
-public class Util {
-  public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
-  public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
-
-  public static final LabelType codeReview() {
-    return category(
-        "Code-Review",
-        value(2, "Looks good to me, approved"),
-        value(1, "Looks good to me, but someone else must approve"),
-        value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
-  }
-
-  public static final LabelType verified() {
-    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-  }
-
-  public static final LabelType patchSetLock() {
-    LabelType label =
-        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunction(LabelFunction.PATCH_SET_LOCK);
-    return label;
-  }
-
-  public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
-  }
-
-  public static LabelType category(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
-  }
-
-  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
-    group = project.resolve(group);
-
-    return new PermissionRule(group);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref);
-  }
-
-  public static PermissionRule allowExclusive(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref, true);
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    PermissionRule r = grant(project, permissionName, rule, ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    return grant(project, permissionName, newRule(project, group), ref);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      AccountGroup.UUID group,
-      String ref,
-      boolean exclusive) {
-    return grant(project, permissionName, newRule(project, group), ref, exclusive);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    return allow(project, capabilityName, group, (PermissionRange) null);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String capabilityName,
-      AccountGroup.UUID group,
-      PermissionRange customRange) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    if (GlobalCapability.hasRange(capabilityName)) {
-      if (customRange == null) {
-        PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
-        if (range != null) {
-          rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        }
-        return rule;
-      }
-      rule.setRange(customRange.getMin(), customRange.getMax());
-    }
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule rule = newRule(project, group);
-    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
-    return blockLabel(project, labelName, -1, 1, group, ref);
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project,
-      String labelName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
-    r.setBlock();
-    r.setRange(min, max);
-    return r;
-  }
-
-  public static PermissionRule deny(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setDeny();
-    return r;
-  }
-
-  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
-    project
-        .getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .setExclusiveGroup(true);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
-    return grant(project, permissionName, rule, ref, false);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project,
-      String permissionName,
-      PermissionRule rule,
-      String ref,
-      boolean exclusive) {
-    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
-    if (exclusive) {
-      permission.setExclusiveGroup(exclusive);
-    }
-    permission.add(rule);
-    return rule;
-  }
-
-  private Util() {}
-}
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index cb96bc5..1eed7ea 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -16,14 +16,14 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -44,7 +44,7 @@
     List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
     Integer id = Ints.tryParse(query);
     if (id != null) {
-      preds.add(id(new Account.Id(id)));
+      preds.add(id(schema, Account.id(id)));
     }
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
@@ -64,9 +64,11 @@
     return Predicate.or(preds);
   }
 
-  public static Predicate<AccountState> id(Account.Id accountId) {
+  public static Predicate<AccountState> id(Schema<AccountState> schema, Account.Id accountId) {
     return new AccountPredicate(
-        AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
+        schema.useLegacyNumericFields() ? AccountField.ID : AccountField.ID_STR,
+        AccountQueryBuilder.FIELD_ACCOUNT,
+        accountId.toString());
   }
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 38336c1..42a8310 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,7 +27,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
@@ -204,7 +204,7 @@
 
     if ("self".equalsIgnoreCase(query) || "me".equalsIgnoreCase(query)) {
       try {
-        return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
+        return Predicate.or(defaultPredicate, AccountPredicates.id(args.schema(), self()));
       } catch (QueryParseException e) {
         // Skip.
       }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 7a7381d..2e29bbd 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -77,6 +77,6 @@
 
   @Override
   protected String formatForLogging(AccountState accountState) {
-    return accountState.getAccount().getId().toString();
+    return accountState.account().id().toString();
   }
 }
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index 8bbeb24..0252a06 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -37,7 +37,7 @@
   public boolean match(AccountState accountState) {
     try {
       permissionBackend
-          .absentUser(accountState.getAccount().getId())
+          .absentUser(accountState.account().id())
           .change(changeNotes)
           .check(ChangePermission.READ);
       return true;
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index f2385ec..091edca 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -24,17 +24,16 @@
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.InternalQuery;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.inject.Inject;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
 
@@ -77,7 +76,7 @@
       msg.append("Ambiguous external ID ").append(externalId).append(" for accounts: ");
       Joiner.on(", ")
           .appendTo(
-              msg, accountStates.stream().map(AccountState.ACCOUNT_ID_FUNCTION).collect(toList()));
+              msg, accountStates.stream().map(a -> a.account().id().toString()).collect(toList()));
       logger.atWarning().log(msg.toString());
     }
     return null;
@@ -103,7 +102,7 @@
     }
 
     return query(AccountPredicates.preferredEmail(email)).stream()
-        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+        .filter(a -> a.account().preferredEmail().equals(email))
         .collect(toList());
   }
 
@@ -114,15 +113,13 @@
    * @return multimap of the given emails to accounts that have a preferred email that exactly
    *     matches this email
    */
-  public Multimap<String, AccountState> byPreferredEmail(String... emails) {
-    List<String> emailList = Arrays.asList(emails);
-
+  public Multimap<String, AccountState> byPreferredEmail(List<String> emails) {
     if (hasPreferredEmailExact()) {
       List<List<AccountState>> r =
-          query(emailList.stream().map(AccountPredicates::preferredEmailExact).collect(toList()));
+          query(emails.stream().map(AccountPredicates::preferredEmailExact).collect(toList()));
       Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
-      for (int i = 0; i < emailList.size(); i++) {
-        accountsByEmail.putAll(emailList.get(i), r.get(i));
+      for (int i = 0; i < emails.size(); i++) {
+        accountsByEmail.putAll(emails.get(i), r.get(i));
       }
       return accountsByEmail;
     }
@@ -132,13 +129,13 @@
     }
 
     List<List<AccountState>> r =
-        query(emailList.stream().map(AccountPredicates::preferredEmail).collect(toList()));
+        query(emails.stream().map(AccountPredicates::preferredEmail).collect(toList()));
     Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
-    for (int i = 0; i < emailList.size(); i++) {
-      String email = emailList.get(i);
+    for (int i = 0; i < emails.size(); i++) {
+      String email = emails.get(i);
       Set<AccountState> matchingAccounts =
           r.get(i).stream()
-              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+              .filter(a -> a.account().preferredEmail().equals(email))
               .collect(toSet());
       accountsByEmail.putAll(email, matchingAccounts);
     }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index 1cf2c2f..36eb5b7 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -17,7 +17,7 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index fb19e85..35a91c9 100644
--- a/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class AssigneePredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 3cd64b3..c6beac4 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -19,6 +19,7 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -34,19 +35,19 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -74,6 +75,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -222,12 +224,18 @@
    * @return instance for testing.
    */
   public static ChangeData createForTest(
-      Project.NameKey project, Change.Id id, int currentPatchSetId) {
+      Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
             null, project, id, null, null);
-    cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
+    cd.currentPatchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(id, currentPatchSetId))
+            .commitId(commitId)
+            .uploader(Account.id(1000))
+            .createdOn(TimeUtil.nowTs())
+            .build();
     return cd;
   }
 
@@ -357,6 +365,7 @@
     return allUsersName;
   }
 
+  @VisibleForTesting
   public void setCurrentFilePaths(List<String> filePaths) {
     PatchSet ps = currentPatchSet();
     if (ps != null) {
@@ -387,7 +396,7 @@
         return Optional.empty();
       }
 
-      ObjectId id = ObjectId.fromString(ps.getRevision().get());
+      ObjectId id = ps.commitId();
       Whitespace ws = Whitespace.IGNORE_NONE;
       PatchListKey pk =
           parentCount > 1
@@ -497,7 +506,7 @@
         return null;
       }
       for (PatchSet p : patchSets()) {
-        if (p.getId().equals(c.currentPatchSetId())) {
+        if (p.id().equals(c.currentPatchSetId())) {
           currentPatchSet = p;
           return p;
         }
@@ -580,10 +589,9 @@
     if (ps == null) {
       return false;
     }
-    String sha1 = ps.getRevision().get();
     try (Repository repo = repoManager.openRepository(project());
         RevWalk walk = new RevWalk(repo)) {
-      RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
+      RevCommit c = walk.parseCommit(ps.commitId());
       commitMessage = c.getFullMessage();
       commitFooters = c.getFooterLines();
       author = c.getAuthorIdent();
@@ -610,11 +618,11 @@
 
   /** @return patch with the given ID, or null if it does not exist. */
   public PatchSet patchSet(PatchSet.Id psId) {
-    if (currentPatchSet != null && currentPatchSet.getId().equals(psId)) {
+    if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
     }
     for (PatchSet ps : patchSets()) {
-      if (ps.getId().equals(psId)) {
+      if (ps.id().equals(psId)) {
         return ps;
       }
     }
@@ -901,7 +909,7 @@
         }
 
         try (Repository repo = repoManager.openRepository(project())) {
-          Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
+          Ref ref = repo.getRefDatabase().exactRef(c.getDest().branch());
           SubmitTypeRecord str = submitTypeRecord();
           if (!str.isOk()) {
             // If submit type rules are broken, it's definitely not mergeable.
@@ -911,13 +919,7 @@
           String mergeStrategy =
               mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
           mergeable =
-              mergeabilityCache.get(
-                  ObjectId.fromString(ps.getRevision().get()),
-                  ref,
-                  str.type,
-                  mergeStrategy,
-                  c.getDest(),
-                  repo);
+              mergeabilityCache.get(ps.commitId(), ref, str.type, mergeStrategy, c.getDest(), repo);
         } catch (IOException e) {
           throw new StorageException(e);
         }
@@ -995,11 +997,11 @@
 
     PatchSet ps = currentPatchSet();
     if (ps != null) {
-      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.number())) {
         return true;
       }
 
-      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.number())) {
         return false;
       }
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 74ad0ef..05cc6ca 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 8015a33..819fc2b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.InternalUser;
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index f998ad3..df729cb 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
@@ -29,6 +29,11 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -41,11 +46,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -62,10 +62,7 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -94,7 +91,6 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /** Parses a query string meant to be applied to change objects. */
@@ -189,7 +185,7 @@
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
-  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
+  public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
@@ -400,8 +396,6 @@
 
   private final Arguments args;
 
-  private @Inject @GerritServerConfig Config cfg;
-
   @Inject
   ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
@@ -452,13 +446,15 @@
     if (triplet.isPresent()) {
       return Predicate.and(
           project(triplet.get().project().get()),
-          branch(triplet.get().branch().get()),
+          branch(triplet.get().branch().branch()),
           new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return new LegacyChangeIdPredicate(new Change.Id(id));
+        return args.getSchema().useLegacyNumericFields()
+            ? new LegacyChangeIdPredicate(Change.id(id))
+            : new LegacyChangeIdStrPredicate(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return new ChangeIdPredicate(parseChangeId(query));
@@ -566,11 +562,11 @@
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+      return Predicate.not(new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE)));
     }
 
     if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+      return new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
@@ -733,10 +729,6 @@
   @Operator
   public Predicate<ChangeData> extension(String ext) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXTENSION)) {
-      if (ext.isEmpty()
-          && IndexModule.getIndexType(cfg).equalsIgnoreCase(IndexType.ELASTICSEARCH.name())) {
-        return new FileWithNoExtensionInElasticPredicate();
-      }
       return new FileExtensionPredicate(ext);
     }
     throw new QueryParseException("'extension' operator is not supported by change index version");
@@ -775,11 +767,6 @@
       if (directory.startsWith("^")) {
         return new RegexDirectoryPredicate(directory);
       }
-
-      if (IndexModule.getIndexType(cfg).equalsIgnoreCase(IndexType.ELASTICSEARCH.name())
-          && (directory.isEmpty() || directory.equals("/"))) {
-        return Predicate.any();
-      }
       return new DirectoryPredicate(directory);
     }
     throw new QueryParseException("'directory' operator is not supported by change index version");
@@ -1184,7 +1171,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
       d.load(args.allUsersName, git);
-      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
+      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
       }
@@ -1321,7 +1308,7 @@
   private Set<Account.Id> getMembers(AccountGroup.UUID g) throws IOException {
     Set<Account.Id> accounts;
     Set<Account.Id> allMembers =
-        args.groupMembers.listAccounts(g).stream().map(Account::getId).collect(toSet());
+        args.groupMembers.listAccounts(g).stream().map(Account::id).collect(toSet());
     int maxTerms = args.indexConfig.maxTerms();
     if (allMembers.size() > maxTerms) {
       // limit the number of query terms otherwise Gerrit will barf
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 66790e7..88e93d9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -18,9 +18,9 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 0747bb2..bd7981c 100644
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Objects;
 
diff --git a/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
index d193bb6..4b14f08 100644
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -32,9 +33,15 @@
   @Override
   public boolean match(ChangeData object) {
     try {
-      Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
+      Change.Id id = object.getId();
+      Predicate<ChangeData> p =
+          Predicate.and(
+              index.getSchema().useLegacyNumericFields()
+                  ? new LegacyChangeIdPredicate(id)
+                  : new LegacyChangeIdStrPredicate(id),
+              this);
       for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(object.getId())) {
+        if (cData.getId().equals(id)) {
           return true;
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 567f58d..b54ee64 100644
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
 import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
 
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.reviewdb.client.PatchSet;
 
 public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == OBJECT_ID_STRING_LENGTH) {
+    if (id.length() == ObjectIds.STR_LEN) {
       return EXACT_COMMIT;
     }
     return COMMIT;
@@ -45,9 +46,10 @@
   }
 
   protected boolean equals(PatchSet p, String id) {
-    boolean exact = getField() == EXACT_COMMIT;
-    String rev = p.getRevision() != null ? p.getRevision().get() : null;
-    return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
+    if (getField() == EXACT_COMMIT) {
+      return p.commitId().name().equals(id);
+    }
+    return matchesAbbreviation(p.commitId(), id);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index d415f71..17e4a59 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -20,14 +20,14 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -86,8 +86,12 @@
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().get()));
-    and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
+    and.add(new RefPredicate(c.getDest().branch()));
+    and.add(
+        Predicate.not(
+            args.getSchema().useLegacyNumericFields()
+                ? new LegacyChangeIdPredicate(c.getId())
+                : new LegacyChangeIdStrPredicate(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
@@ -97,7 +101,7 @@
 
   private static final class CheckConflict extends PostFilterPredicate<ChangeData> {
     private final Arguments args;
-    private final Branch.NameKey dest;
+    private final BranchNameKey dest;
     private final ChangeDataCache changeDataCache;
 
     CheckConflict(String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
@@ -131,7 +135,7 @@
           return false;
         }
 
-        other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
+        other = object.currentPatchSet().commitId();
         ConflictKey conflictsKey =
             ConflictKey.create(
                 changeDataCache.getTestAgainst(),
@@ -207,7 +211,7 @@
 
     ObjectId getTestAgainst() {
       if (testAgainst == null) {
-        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
+        testAgainst = cd.currentPatchSet().commitId();
       }
       return testAgainst;
     }
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 702745e..3c3d70f 100644
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
 import java.util.Set;
 
 public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
-  protected Set<Branch.NameKey> destinations;
+  protected Set<BranchNameKey> destinations;
 
-  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<BranchNameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
diff --git a/java/com/google/gerrit/server/query/change/EditByPredicate.java b/java/com/google/gerrit/server/query/change/EditByPredicate.java
index dfe7310..0fd66f8 100644
--- a/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class EditByPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index acf2e25..62b1144 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -60,7 +60,7 @@
       return false;
     }
 
-    ProjectState project = projectCache.get(c.getDest().getParentKey());
+    ProjectState project = projectCache.get(c.getDest().project());
     if (project == null) {
       // The project has disappeared.
       //
@@ -76,7 +76,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(object, p.getValue(), p.getAccountId())) {
+        if (match(object, p.value(), p.accountId())) {
           return true;
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index c6ade75e..6683c91 100644
--- a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 
 public class ExactTopicPredicate extends ChangeIndexPredicate {
   public ExactTopicPredicate(String topic) {
diff --git a/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java b/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java
deleted file mode 100644
index d886baf..0000000
--- a/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class FileWithNoExtensionInElasticPredicate extends PostFilterPredicate<ChangeData> {
-
-  private static final String NO_EXT = "";
-
-  public FileWithNoExtensionInElasticPredicate() {
-    super(ChangeField.EXTENSION.getName(), NO_EXT);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getExtensions(cd).contains(NO_EXT);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 140f26b..d558b0f 100644
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -17,10 +17,10 @@
 import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 
@@ -43,7 +43,10 @@
       return false;
     }
     try {
-      Predicate<ChangeData> thisId = new LegacyChangeIdPredicate(cd.getId());
+      Predicate<ChangeData> thisId =
+          index.getSchema().useLegacyNumericFields()
+              ? new LegacyChangeIdPredicate(cd.getId())
+              : new LegacyChangeIdStrPredicate(cd.getId());
       Iterable<ChangeData> results =
           index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
       return !Iterables.isEmpty(results);
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 7f7bcff..99f37d6 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.List;
 
@@ -26,7 +26,7 @@
   @Override
   public boolean match(ChangeData cd) {
     for (PatchSet ps : cd.patchSets()) {
-      List<String> groups = ps.getGroups();
+      List<String> groups = ps.groups();
       if (groups != null && groups.contains(getValue())) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index e57a8b3..6d1576f 100644
--- a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class HasDraftByPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index 0c99cdf..2fbd1e8 100644
--- a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class HasStarsPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 74a0f71..6605c23 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -25,13 +25,13 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -55,8 +55,13 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  private static Predicate<ChangeData> ref(Branch.NameKey branch) {
-    return new RefPredicate(branch.get());
+  @FunctionalInterface
+  static interface ChangeIdPredicateFactory {
+    Predicate<ChangeData> create(Change.Id id);
+  }
+
+  private static Predicate<ChangeData> ref(BranchNameKey branch) {
+    return new RefPredicate(branch.branch());
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
@@ -78,6 +83,9 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
 
+  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
+  private final ChangeIdPredicateFactory predicateFactory;
+
   @Inject
   InternalChangeQuery(
       ChangeQueryProcessor queryProcessor,
@@ -88,6 +96,11 @@
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
+    predicateFactory =
+        (id) ->
+            schema().useLegacyNumericFields()
+                ? new LegacyChangeIdPredicate(id)
+                : new LegacyChangeIdStrPredicate(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -99,48 +112,48 @@
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
-    return query(new LegacyChangeIdPredicate(id));
+    return query(predicateFactory.create(id));
   }
 
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
-      preds.add(new LegacyChangeIdPredicate(id));
+      preds.add(predicateFactory.create(id));
     }
     return query(or(preds));
   }
 
-  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) {
+  public List<ChangeData> byBranchKey(BranchNameKey branch, Change.Key key) {
     return query(byBranchKeyPred(branch, key));
   }
 
   public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open()));
+    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
   }
 
   public static Predicate<ChangeData> byBranchKeyOpenPred(
       Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open());
+    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
   }
 
-  private static Predicate<ChangeData> byBranchKeyPred(Branch.NameKey branch, Change.Key key) {
-    return and(ref(branch), project(branch.getParentKey()), change(key));
+  private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.project()), change(key));
   }
 
   public List<ChangeData> byProject(Project.NameKey project) {
     return query(project(project));
   }
 
-  public List<ChangeData> byBranchOpen(Branch.NameKey branch) {
-    return query(and(ref(branch), project(branch.getParentKey()), open()));
+  public List<ChangeData> byBranchOpen(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), open()));
   }
 
-  public List<ChangeData> byBranchNew(Branch.NameKey branch) {
-    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
+  public List<ChangeData> byBranchNew(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), status(Change.Status.NEW)));
   }
 
   public Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes) throws IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     return byCommitsOnBranchNotMerged(
         repo,
         branch,
@@ -151,7 +164,7 @@
 
   @VisibleForTesting
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes, int indexLimit)
+      Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
       throws IOException {
     if (hashes.size() > indexLimit) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
@@ -160,7 +173,7 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes) throws IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
     for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
@@ -180,7 +193,7 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
-            branch.getParentKey(),
+            branch.project(),
             changeIds,
             cn -> {
               Change c = cn.getChange();
@@ -190,11 +203,11 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, Collection<String> hashes) {
+      BranchNameKey branch, Collection<String> hashes) {
     return query(
         and(
             ref(branch),
-            project(branch.getParentKey()),
+            project(branch.project()),
             not(status(Change.Status.MERGED)),
             or(commits(hashes))));
   }
@@ -241,8 +254,8 @@
     return query(byBranchCommitPred(project, branch, hash));
   }
 
-  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) {
-    return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
+  public List<ChangeData> byBranchCommit(BranchNameKey branch, String hash) {
+    return byBranchCommit(branch.project().get(), branch.branch(), hash);
   }
 
   public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash) {
diff --git a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index ddb6f32..4b32b06 100644
--- a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -16,8 +16,8 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.REVIEWEDBY;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -25,7 +25,7 @@
 import java.util.Set;
 
 public class IsReviewedPredicate extends ChangeIndexPredicate {
-  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+  protected static final Account.Id NOT_REVIEWED = Account.id(ChangeField.NOT_REVIEWED);
 
   public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
diff --git a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index c52d2a9..3a43fd3 100644
--- a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -93,7 +93,7 @@
       throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
-      return user.asIdentifiedUser().state().getProjectWatches().keySet();
+      return user.asIdentifiedUser().state().projectWatches().keySet();
     }
     return Collections.emptySet();
   }
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index b5d375c..38d1dbe 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.RangeUtil;
 import com.google.gerrit.index.query.RangeUtil.Range;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index fe4d4e1..d531236 100644
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
new file mode 100644
index 0000000..bae9c0d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
+
+import com.google.gerrit.entities.Change;
+
+/** Predicate over change number (aka legacy ID or Change.Id). */
+public class LegacyChangeIdStrPredicate extends ChangeIndexPredicate {
+  protected final Change.Id id;
+
+  public LegacyChangeIdStrPredicate(Change.Id id) {
+    super(LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+    this.id = id;
+  }
+
+  @Override
+  public boolean match(ChangeData object) {
+    return id.equals(object.getId());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 0bd8c88..44cbd8e 100644
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -33,7 +33,12 @@
   @Override
   public boolean match(ChangeData object) {
     try {
-      Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
+      Predicate<ChangeData> p =
+          Predicate.and(
+              index.getSchema().useLegacyNumericFields()
+                  ? new LegacyChangeIdPredicate(object.getId())
+                  : new LegacyChangeIdStrPredicate(object.getId()),
+              this);
       for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
         if (cData.getId().equals(object.getId())) {
           return true;
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index ba06b89..983d9b4 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -14,17 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.index.query.ListResultSet;
+import com.google.gerrit.index.query.LazyResultSet;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
-import com.google.gerrit.reviewdb.client.Change;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 public class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
@@ -36,22 +40,29 @@
 
   @Override
   public ResultSet<ChangeData> read() {
-    // TODO(spearce) This probably should be more lazy.
-    //
-    List<ChangeData> r = new ArrayList<>();
-    Set<Change.Id> have = new HashSet<>();
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (p instanceof ChangeDataSource) {
-        for (ChangeData cd : ((ChangeDataSource) p).read()) {
-          if (have.add(cd.getId())) {
-            r.add(cd);
-          }
-        }
-      } else {
-        throw new StorageException("No ChangeDataSource: " + p);
-      }
+    Optional<Predicate<ChangeData>> nonChangeDataSource =
+        getChildren().stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
+    if (nonChangeDataSource.isPresent()) {
+      throw new StorageException("No ChangeDataSource: " + nonChangeDataSource.get());
     }
-    return new ListResultSet<>(r);
+
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    List<ResultSet<ChangeData>> results =
+        getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
+    return new LazyResultSet<>(
+        () -> {
+          List<ChangeData> r = new ArrayList<>();
+          Set<Change.Id> have = new HashSet<>();
+          for (ResultSet<ChangeData> resultSet : results) {
+            for (ChangeData result : resultSet) {
+              if (have.add(result.getId())) {
+                r.add(result);
+              }
+            }
+          }
+          return ImmutableList.copyOf(r);
+        });
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 08e6f33..e875499 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -19,11 +19,11 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
diff --git a/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index 100a66c..923a9ca 100644
--- a/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class OwnerPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index 41b3204..c2bc5d9 100644
--- a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 
 public class OwnerinPredicate extends PostFilterPredicate<ChangeData> {
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 17d6448..5deb7f5 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
@@ -40,7 +40,7 @@
 
   protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
-    ProjectState projectState = projectCache.get(new Project.NameKey(value));
+    ProjectState projectState = projectCache.get(Project.nameKey(value));
     if (projectState == null) {
       return Collections.emptyList();
     }
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index febd6c6..db5a932 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class ProjectPredicate extends ChangeIndexPredicate {
@@ -24,7 +24,7 @@
   }
 
   protected Project.NameKey getValueKey() {
-    return new Project.NameKey(getValue());
+    return Project.nameKey(getValue());
   }
 
   @Override
@@ -34,7 +34,7 @@
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return p.equals(getValueKey());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 744f4d2..c23a175 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class ProjectPrefixPredicate extends ChangeIndexPredicate {
@@ -25,7 +25,7 @@
   @Override
   public boolean match(ChangeData object) {
     Change c = object.change();
-    return c != null && c.getDest().getParentKey().get().startsWith(getValue());
+    return c != null && c.getDest().project().get().startsWith(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
index 2ed4c99..1c70a62 100644
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class RefPredicate extends ChangeIndexPredicate {
@@ -28,7 +28,7 @@
     if (change == null) {
       return false;
     }
-    return getValue().equals(change.getDest().get());
+    return getValue().equals(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 46a17f6..bbdfc66 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.index.change.ChangeField;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -44,7 +44,7 @@
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return pattern.run(p.get());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index af211e6..6d404b4 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -42,7 +42,7 @@
     if (change == null) {
       return false;
     }
-    return pattern.run(change.getDest().get());
+    return pattern.run(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index 0441afa..7713f24 100644
--- a/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 19104d3..d783f76 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 
diff --git a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 542a357..e5c6647 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
index a49e8c5..c451d46 100644
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
index 6c5fd78..788f1a3 100644
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 
diff --git a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index 0995a59..093447e 100644
--- a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class SubmissionIdPredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index e59ae43..bc65422 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -17,8 +17,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index d02f6a4..17a7000 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 215e36f..4e60db5 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -20,13 +20,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.GroupBackend;
@@ -75,7 +75,7 @@
 
   @Operator
   public Predicate<InternalGroup> uuid(String uuid) {
-    return GroupPredicates.uuid(new AccountGroup.UUID(uuid));
+    return GroupPredicates.uuid(AccountGroup.uuid(uuid));
   }
 
   @Operator
@@ -169,7 +169,7 @@
   }
 
   private AccountGroup.UUID parseGroup(String groupNameOrUuid) throws QueryParseException {
-    Optional<InternalGroup> group = args.groupCache.get(new AccountGroup.UUID(groupNameOrUuid));
+    Optional<InternalGroup> group = args.groupCache.get(AccountGroup.uuid(groupNameOrUuid));
     if (group.isPresent()) {
       return group.get().getGroupUUID();
     }
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 5749809..5732873 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -19,11 +19,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 5f13236..4e56fac 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.index.project.ProjectPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Project;
 import java.util.Locale;
 
 public class ProjectPredicates {
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index 8b4cb53..d234546 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -17,13 +17,13 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import java.util.List;
 
@@ -41,12 +41,12 @@
 
   @Operator
   public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(new Project.NameKey(name));
+    return ProjectPredicates.name(Project.nameKey(name));
   }
 
   @Operator
   public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(new Project.NameKey(parentName));
+    return ProjectPredicates.parent(Project.nameKey(parentName));
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
index d39e55c..c659975 100644
--- a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
+++ b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
@@ -18,9 +18,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
diff --git a/java/com/google/gerrit/server/quota/QuotaBackend.java b/java/com/google/gerrit/server/quota/QuotaBackend.java
index 11ce61f..694f4f1 100644
--- a/java/com/google/gerrit/server/quota/QuotaBackend.java
+++ b/java/com/google/gerrit/server/quota/QuotaBackend.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.quota;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.ImplementedBy;
 
diff --git a/java/com/google/gerrit/server/quota/QuotaRequestContext.java b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
index 90b501c..bfa7a35 100644
--- a/java/com/google/gerrit/server/quota/QuotaRequestContext.java
+++ b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.quota;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index c482019..fd341e9 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -10,8 +10,10 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
@@ -20,7 +22,6 @@
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/util/time",
@@ -29,7 +30,8 @@
         "//lib:blame-cache",
         "//lib:gson",
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
@@ -38,6 +40,5 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index a3e9530..2520821 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -14,16 +14,13 @@
 
 package com.google.gerrit.server.restapi.access;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -47,13 +44,12 @@
   }
 
   @Override
-  public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException {
+  public Response<Map<String, ProjectAccessInfo>> apply(TopLevelResource resource)
+      throws Exception {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(new Project.NameKey(p)));
+      access.put(p, getAccess.apply(Project.nameKey(p)));
     }
-    return access;
+    return Response.ok(access);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index 7c05f4e..1fcf0bd 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -104,7 +104,7 @@
         addKeyFactory.create(user, sshKey).send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
-            "Cannot send SSH key added message to %s", user.getAccount().getPreferredEmail());
+            "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
       }
 
       user.getUserName().ifPresent(sshKeyCache::evict);
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index bf50e10..c110194 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -34,8 +36,6 @@
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -119,7 +119,7 @@
 
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     List<ExternalId> extIds = new ArrayList<>();
 
     if (input.email != null) {
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index cc4cf21..ae45b68 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -97,11 +97,11 @@
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
 
-    return apply(rsrc.getUser(), id, input);
+    return Response.created(apply(rsrc.getUser(), id, input));
   }
 
   /** To be used from plugins that want to create emails without permission checks. */
-  public Response<EmailInfo> apply(IdentifiedUser user, IdString id, EmailInput input)
+  public EmailInfo apply(IdentifiedUser user, IdString id, EmailInput input)
       throws RestApiException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
     String email = id.get().trim();
@@ -146,6 +146,6 @@
         throw e;
       }
     }
-    return Response.created(info);
+    return info;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 4788301..a6fbb10 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,26 +15,27 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -107,7 +108,7 @@
   }
 
   @Override
-  public ImmutableList<DeletedDraftCommentInfo> apply(
+  public Response<ImmutableList<DeletedDraftCommentInfo>> apply(
       AccountResource rsrc, DeleteDraftCommentsInput input)
       throws RestApiException, UpdateException {
     CurrentUser user = userProvider.get();
@@ -147,7 +148,8 @@
     // allowing partial failure would have little value.
     BatchUpdate.execute(updates.values(), BatchUpdateListener.NONE, false);
 
-    return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+    return Response.ok(
+        ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
   }
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
@@ -180,8 +182,8 @@
       boolean dirty = false;
       for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
-        PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), c.key.patchSetId);
-        setCommentRevId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
         commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
         comments.add(commentFormatter.format(c));
       }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteEmail.java b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
index 72a67db..5cdf4ae 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toSet;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.Input;
@@ -25,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index 054f4bc..b470be8 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -74,7 +74,7 @@
       deleteKeySenderFactory.create(user, rsrc.getSshKey()).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
-          "Cannot send SSH key deletion message to %s", user.getAccount().getPreferredEmail());
+          "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
     }
     user.getUserName().ifPresent(sshKeyCache::evict);
 
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
index 798aad1..9f38b97 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
@@ -16,13 +16,13 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -77,7 +77,7 @@
                 u.deleteProjectWatches(
                     input.stream()
                         .filter(Objects::nonNull)
-                        .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
+                        .map(w -> ProjectWatchKey.create(Project.nameKey(w.project), w.filter))
                         .collect(toList())));
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
index 434b9d6..7a498f4 100644
--- a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
@@ -63,7 +63,7 @@
     }
 
     if ("preferred".equals(id.get())) {
-      String email = rsrc.getUser().getAccount().getPreferredEmail();
+      String email = rsrc.getUser().getAccount().preferredEmail();
       if (Strings.isNullOrEmpty(email)) {
         throw new ResourceNotFoundException(id);
       }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAccount.java b/java/com/google/gerrit/server/restapi/account/GetAccount.java
index 6544f8d..898b0bb 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAccount.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
@@ -32,10 +33,10 @@
   }
 
   @Override
-  public AccountInfo apply(AccountResource rsrc) throws PermissionBackendException {
+  public Response<AccountInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getUser().getAccountId());
     loader.fill();
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index edcbc35..5feca66 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -18,12 +18,13 @@
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -61,7 +62,7 @@
   }
 
   @Override
-  public List<AgreementInfo> apply(AccountResource resource)
+  public Response<List<AgreementInfo>> apply(AccountResource resource)
       throws RestApiException, PermissionBackendException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
@@ -97,6 +98,6 @@
         results.add(agreementJson.format(ca));
       }
     }
-    return results;
+    return Response.ok(results);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
index 904b15f..e97e0a0 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc) throws ResourceNotFoundException {
+  public Response<String> apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
       throw new ResourceNotFoundException();
@@ -43,6 +44,6 @@
     if (Strings.isNullOrEmpty(url)) {
       throw new ResourceNotFoundException();
     }
-    return url;
+    return Response.ok(url);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index 77f1668..fa9ab18 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.account.AccountLimits;
@@ -40,7 +40,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -79,7 +78,7 @@
   }
 
   @Override
-  public Object apply(AccountResource resource)
+  public Response<Map<String, Object>> apply(AccountResource resource)
       throws RestApiException, PermissionBackendException {
     permissionBackend.checkUsesDefaultCapabilities();
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
@@ -97,9 +96,7 @@
     addRanges(have, limits);
     addPriority(have, limits);
 
-    return OutputFormat.JSON
-        .newGson()
-        .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
+    return Response.ok(have);
   }
 
   private Set<GlobalOrPluginPermission> permissionsToTest() {
@@ -172,9 +169,9 @@
     }
 
     @Override
-    public BinaryResult apply(Capability resource) throws ResourceNotFoundException {
+    public Response<BinaryResult> apply(Capability resource) throws ResourceNotFoundException {
       permissionBackend.checkUsesDefaultCapabilities();
-      return BinaryResult.create("ok\n");
+      return Response.ok(BinaryResult.create("ok\n"));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index 72044c4..b19559e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.InternalAccountDirectory;
@@ -36,12 +37,12 @@
   }
 
   @Override
-  public AccountDetailInfo apply(AccountResource rsrc) throws PermissionBackendException {
+  public Response<AccountDetailInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
-    AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
-    info.registeredOn = a.getRegisteredOn();
+    AccountDetailInfo info = new AccountDetailInfo(a.id().get());
+    info.registeredOn = a.registeredOn();
     info.inactive = !a.isActive() ? true : null;
     directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
index 40201a8..670ef3b 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
@@ -48,16 +49,17 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc)
+  public Response<DiffPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, ConfigInvalidException, IOException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getDiffPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountCache
+            .get(id)
+            .map(AccountState::diffPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
index 0ecd6ea..409209e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
@@ -48,16 +49,17 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(AccountResource rsrc)
+  public Response<EditPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getEditPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountCache
+            .get(id)
+            .map(AccountState::editPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmail.java b/java/com/google/gerrit/server/restapi/account/GetEmail.java
index 3118380..afcdac2 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmail.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Inject;
@@ -26,10 +27,10 @@
   public GetEmail() {}
 
   @Override
-  public EmailInfo apply(AccountResource.Email rsrc) {
+  public Response<EmailInfo> apply(AccountResource.Email rsrc) {
     EmailInfo e = new EmailInfo();
     e.email = rsrc.getEmail();
-    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-    return e;
+    e.preferred(rsrc.getUser().getAccount().preferredEmail());
+    return Response.ok(e);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 8c21536..9db9f05 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -43,22 +44,23 @@
   }
 
   @Override
-  public List<EmailInfo> apply(AccountResource rsrc)
+  public Response<List<EmailInfo>> apply(AccountResource rsrc)
       throws AuthException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
-    return rsrc.getUser().getEmailAddresses().stream()
-        .filter(Objects::nonNull)
-        .map(e -> toEmailInfo(rsrc, e))
-        .sorted(comparing((EmailInfo e) -> e.email))
-        .collect(toList());
+    return Response.ok(
+        rsrc.getUser().getEmailAddresses().stream()
+            .filter(Objects::nonNull)
+            .map(e -> toEmailInfo(rsrc, e))
+            .sorted(comparing((EmailInfo e) -> e.email))
+            .collect(toList()));
   }
 
   private static EmailInfo toEmailInfo(AccountResource rsrc, String email) {
     EmailInfo e = new EmailInfo();
     e.email = email;
-    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
+    e.preferred(rsrc.getUser().getAccount().preferredEmail());
     return e;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index ef448dc..0e52af2 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
@@ -58,7 +59,7 @@
   }
 
   @Override
-  public List<AccountExternalIdInfo> apply(AccountResource resource)
+  public Response<List<AccountExternalIdInfo>> apply(AccountResource resource)
       throws RestApiException, IOException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
@@ -66,7 +67,7 @@
 
     Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
     if (ids.isEmpty()) {
-      return ImmutableList.of();
+      return Response.ok(ImmutableList.of());
     }
     List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
     for (ExternalId id : ids) {
@@ -83,7 +84,7 @@
       }
       result.add(info);
     }
-    return result;
+    return Response.ok(result);
   }
 
   private static Boolean toBoolean(boolean v) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetGroups.java b/java/com/google/gerrit/server/restapi/account/GetGroups.java
index 569ff76..22492a7 100644
--- a/java/com/google/gerrit/server/restapi/account/GetGroups.java
+++ b/java/com/google/gerrit/server/restapi/account/GetGroups.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GroupControl;
@@ -42,7 +43,8 @@
   }
 
   @Override
-  public List<GroupInfo> apply(AccountResource resource) throws PermissionBackendException {
+  public Response<List<GroupInfo>> apply(AccountResource resource)
+      throws PermissionBackendException {
     IdentifiedUser user = resource.getUser();
     Account.Id userId = user.getAccountId();
     Set<AccountGroup.UUID> knownGroups = user.getEffectiveGroups().getKnownGroups();
@@ -58,6 +60,6 @@
         visibleGroups.add(json.format(ctl.getGroup()));
       }
     }
-    return visibleGroups;
+    return Response.ok(visibleGroups);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetName.java b/java/com/google/gerrit/server/restapi/account/GetName.java
index bdf379e..ca33887 100644
--- a/java/com/google/gerrit/server/restapi/account/GetName.java
+++ b/java/com/google/gerrit/server/restapi/account/GetName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetName implements RestReadView<AccountResource> {
   @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getFullName());
+  public Response<String> apply(AccountResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getUser().getAccount().fullName()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
index 395c159..24682c0 100644
--- a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
+++ b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -50,7 +51,7 @@
   }
 
   @Override
-  public OAuthTokenInfo apply(AccountResource rsrc)
+  public Response<OAuthTokenInfo> apply(AccountResource rsrc)
       throws AuthException, ResourceNotFoundException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       throw new AuthException("not allowed to get access token");
@@ -66,7 +67,7 @@
     accessTokenInfo.providerId = accessToken.getProviderId();
     accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
     accessTokenInfo.type = BEARER_TYPE;
-    return accessTokenInfo;
+    return Response.ok(accessTokenInfo);
   }
 
   private static String getHostName(String canonicalWebUrl) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetPreferences.java b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
index 3d20642..d4d73c5 100644
--- a/java/com/google/gerrit/server/restapi/account/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
@@ -54,7 +55,7 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc)
+  public Response<GeneralPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
@@ -64,9 +65,9 @@
     GeneralPreferencesInfo preferencesInfo =
         accountCache
             .get(id)
-            .map(AccountState::getGeneralPreferences)
+            .map(AccountState::generalPreferences)
             .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
-    return unsetDownloadSchemeIfUnsupported(preferencesInfo);
+    return Response.ok(unsetDownloadSchemeIfUnsupported(preferencesInfo));
   }
 
   private GeneralPreferencesInfo unsetDownloadSchemeIfUnsupported(
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKey.java b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
index dc72663..58b5d12 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountResource.SshKey;
@@ -24,7 +25,7 @@
 public class GetSshKey implements RestReadView<AccountResource.SshKey> {
 
   @Override
-  public SshKeyInfo apply(SshKey rsrc) {
-    return GetSshKeys.newSshKeyInfo(rsrc.getSshKey());
+  public Response<SshKeyInfo> apply(SshKey rsrc) {
+    return Response.ok(GetSshKeys.newSshKeyInfo(rsrc.getSshKey()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 408aa5f..0ca9b9e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -53,13 +54,13 @@
   }
 
   @Override
-  public List<SshKeyInfo> apply(AccountResource rsrc)
+  public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
       throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
-    return apply(rsrc.getUser());
+    return Response.ok(apply(rsrc.getUser()));
   }
 
   public List<SshKeyInfo> apply(IdentifiedUser user)
diff --git a/java/com/google/gerrit/server/restapi/account/GetStatus.java b/java/com/google/gerrit/server/restapi/account/GetStatus.java
index bc7094f..447ad76 100644
--- a/java/com/google/gerrit/server/restapi/account/GetStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/GetStatus.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetStatus implements RestReadView<AccountResource> {
   @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getStatus());
+  public Response<String> apply(AccountResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getUser().getAccount().status()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetUsername.java b/java/com/google/gerrit/server/restapi/account/GetUsername.java
index 01185c3..7e58f94 100644
--- a/java/com/google/gerrit/server/restapi/account/GetUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/GetUsername.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Inject;
@@ -27,7 +28,8 @@
   public GetUsername() {}
 
   @Override
-  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
-    return rsrc.getUser().getUserName().orElseThrow(ResourceNotFoundException::new);
+  public Response<String> apply(AccountResource rsrc)
+      throws AuthException, ResourceNotFoundException {
+    return Response.ok(rsrc.getUser().getUserName().orElseThrow(ResourceNotFoundException::new));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index fce324e..353e3f6 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -19,11 +19,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
@@ -55,7 +56,7 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc)
+  public Response<List<ProjectWatchInfo>> apply(AccountResource rsrc)
       throws AuthException, IOException, ConfigInvalidException, PermissionBackendException,
           ResourceNotFoundException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
@@ -64,12 +65,13 @@
 
     Account.Id accountId = rsrc.getUser().getAccountId();
     AccountState account = accounts.get(accountId).orElseThrow(ResourceNotFoundException::new);
-    return account.getProjectWatches().entrySet().stream()
-        .map(e -> toProjectWatchInfo(e.getKey(), e.getValue()))
-        .sorted(
-            comparing((ProjectWatchInfo pwi) -> pwi.project)
-                .thenComparing(pwi -> Strings.nullToEmpty(pwi.filter)))
-        .collect(toList());
+    return Response.ok(
+        account.projectWatches().entrySet().stream()
+            .map(e -> toProjectWatchInfo(e.getKey(), e.getValue()))
+            .sorted(
+                comparing((ProjectWatchInfo pwi) -> pwi.project)
+                    .thenComparing(pwi -> Strings.nullToEmpty(pwi.filter)))
+            .collect(toList()));
   }
 
   private static ProjectWatchInfo toProjectWatchInfo(
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 14bd492..5236174 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.IdentifiedUser;
@@ -64,7 +65,7 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+  public Response<List<ProjectWatchInfo>> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 147eec8..b4b3314 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,7 +28,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
@@ -93,7 +93,7 @@
 
     AccountState accountState = self.get().state();
     try {
-      addMembers.addMembers(uuid, ImmutableSet.of(accountState.getAccount().getId()));
+      addMembers.addMembers(uuid, ImmutableSet.of(accountState.account().id()));
     } catch (NoSuchGroupException e) {
       throw new ResourceConflictException("autoverify group not found", e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index f029ef9..3e7753f 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -58,7 +58,7 @@
     try {
       rng = SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("Cannot create RNG for password generator", e);
+      throw new IllegalStateException("Cannot create RNG for password generator", e);
     }
   }
 
@@ -133,7 +133,7 @@
           .send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
-          "Cannot send HttpPassword update message to %s", user.getAccount().getPreferredEmail());
+          "Cannot send HttpPassword update message to %s", user.getAccount().preferredEmail());
     }
 
     return Strings.isNullOrEmpty(newPassword) ? Response.none() : Response.ok(newPassword);
diff --git a/java/com/google/gerrit/server/restapi/account/PutName.java b/java/com/google/gerrit/server/restapi/account/PutName.java
index 9f9dd07..0389014 100644
--- a/java/com/google/gerrit/server/restapi/account/PutName.java
+++ b/java/com/google/gerrit/server/restapi/account/PutName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
@@ -91,8 +91,8 @@
             .get()
             .update("Set Full Name via API", accountId, u -> u.setFullName(newName))
             .orElseThrow(() -> new ResourceNotFoundException("account not found"));
-    return Strings.isNullOrEmpty(accountState.getAccount().getFullName())
+    return Strings.isNullOrEmpty(accountState.account().fullName())
         ? Response.none()
-        : Response.ok(accountState.getAccount().getFullName());
+        : Response.ok(accountState.account().fullName());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index b8edec3..2ddea2f 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -85,13 +85,13 @@
             "Set Preferred Email via API",
             user.getAccountId(),
             (a, u) -> {
-              if (preferredEmail.equals(a.getAccount().getPreferredEmail())) {
+              if (preferredEmail.equals(a.account().preferredEmail())) {
                 alreadyPreferred.set(true);
               } else {
                 // check if the user has a matching email
                 String matchingEmail = null;
                 for (String email :
-                    a.getExternalIds().stream()
+                    a.externalIds().stream()
                         .map(ExternalId::email)
                         .filter(Objects::nonNull)
                         .collect(toSet())) {
@@ -128,7 +128,7 @@
                     }
 
                     // claim the email now
-                    u.addExternalId(ExternalId.createEmail(a.getAccount().getId(), preferredEmail));
+                    u.addExternalId(ExternalId.createEmail(a.account().id(), preferredEmail));
                     matchingEmail = preferredEmail;
                   } else {
                     // Realm says that the email doesn't belong to the user. This can only happen as
diff --git a/java/com/google/gerrit/server/restapi/account/PutStatus.java b/java/com/google/gerrit/server/restapi/account/PutStatus.java
index 4f1128d..7e27489 100644
--- a/java/com/google/gerrit/server/restapi/account/PutStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/PutStatus.java
@@ -73,8 +73,8 @@
             .get()
             .update("Set Status via API", user.getAccountId(), u -> u.setStatus(newStatus))
             .orElseThrow(() -> new ResourceNotFoundException("account not found"));
-    return Strings.isNullOrEmpty(accountState.getAccount().getStatus())
+    return Strings.isNullOrEmpty(accountState.account().status())
         ? Response.none()
-        : Response.ok(accountState.getAccount().getStatus());
+        : Response.ok(accountState.account().status());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index 8d949b8..d5897e5 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -17,15 +17,17 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.accounts.UsernameInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -70,18 +72,12 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc, UsernameInput input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+  public Response<String> apply(AccountResource rsrc, UsernameInput input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    if (input == null) {
-      input = new UsernameInput();
-    }
-
     Account.Id accountId = rsrc.getUser().getAccountId();
     if (!externalIds.byAccount(accountId, SCHEME_USERNAME).isEmpty()) {
       throw new MethodNotAllowedException("Username cannot be changed.");
@@ -92,8 +88,8 @@
       throw new MethodNotAllowedException("realm does not allow editing username");
     }
 
-    if (Strings.isNullOrEmpty(input.username)) {
-      return input.username;
+    if (input == null || Strings.isNullOrEmpty(input.username)) {
+      throw new BadRequestException("input required");
     }
 
     if (!ExternalId.isValidUsername(input.username)) {
@@ -112,7 +108,7 @@
       // If we are using this identity, don't report the exception.
       Optional<ExternalId> other = externalIds.get(key);
       if (other.isPresent() && other.get().accountId().equals(accountId)) {
-        return input.username;
+        return Response.ok(input.username);
       }
 
       // Otherwise, someone else has this identity.
@@ -120,6 +116,6 @@
     }
 
     sshKeyCache.evict(input.username);
-    return input.username;
+    return Response.ok(input.username);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 522301d..f9e753c 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,13 +24,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -149,14 +150,14 @@
   }
 
   @Override
-  public List<AccountInfo> apply(TopLevelResource rsrc)
+  public Response<List<AccountInfo>> apply(TopLevelResource rsrc)
       throws RestApiException, PermissionBackendException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
 
     if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
-      return Collections.emptyList();
+      return Response.ok(Collections.emptyList());
     }
 
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
@@ -216,7 +217,7 @@
       }
       QueryResult<AccountState> result = queryProcessor.query(queryPred);
       for (AccountState accountState : result.entities()) {
-        Account.Id id = accountState.getAccount().getId();
+        Account.Id id = accountState.account().id();
         matches.put(id, accountLoader.get(id));
       }
 
@@ -227,10 +228,10 @@
       if (!sorted.isEmpty() && result.more()) {
         sorted.get(sorted.size() - 1)._moreAccounts = true;
       }
-      return sorted;
+      return Response.ok(sorted);
     } catch (QueryParseException e) {
       if (suggest) {
-        return ImmutableList.of();
+        return Response.ok(ImmutableList.of());
       }
       throw new BadRequestException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
index ee72ab7..cf56965 100644
--- a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -53,7 +54,7 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo input)
+  public Response<DiffPreferencesInfo> apply(AccountResource rsrc, DiffPreferencesInfo input)
       throws RestApiException, ConfigInvalidException, RepositoryNotFoundException, IOException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
@@ -65,10 +66,11 @@
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountsUpdateProvider
-        .get()
-        .update("Set Diff Preferences via API", id, u -> u.setDiffPreferences(input))
-        .map(AccountState::getDiffPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set Diff Preferences via API", id, u -> u.setDiffPreferences(input))
+            .map(AccountState::diffPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
index 27d32f2..085adaa 100644
--- a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -54,7 +55,7 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo input)
+  public Response<EditPreferencesInfo> apply(AccountResource rsrc, EditPreferencesInfo input)
       throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
@@ -66,10 +67,11 @@
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountsUpdateProvider
-        .get()
-        .update("Set Edit Preferences via API", id, u -> u.setEditPreferences(input))
-        .map(AccountState::getEditPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set Edit Preferences via API", id, u -> u.setEditPreferences(input))
+            .map(AccountState::editPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/SetPreferences.java b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
index c6623db..3f2211e 100644
--- a/java/com/google/gerrit/server/restapi/account/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -22,15 +23,15 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,21 +61,22 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo input)
+  public Response<GeneralPreferencesInfo> apply(AccountResource rsrc, GeneralPreferencesInfo input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     checkDownloadScheme(input.downloadScheme);
-    Preferences.validateMy(input.my);
+    StoredPreferences.validateMy(input.my);
     Account.Id id = rsrc.getUser().getAccountId();
 
-    return accountsUpdateProvider
-        .get()
-        .update("Set General Preferences via API", id, u -> u.setGeneralPreferences(input))
-        .map(AccountState::getGeneralPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set General Preferences via API", id, u -> u.setGeneralPreferences(input))
+            .map(AccountState::generalPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 
   private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index c610adf..cdaa99d 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -97,14 +98,23 @@
 
     @Override
     @SuppressWarnings("unchecked")
-    public List<ChangeInfo> apply(AccountResource rsrc)
-        throws BadRequestException, AuthException, PermissionBackendException {
+    public Response<List<ChangeInfo>> apply(AccountResource rsrc) throws Exception {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to list stars of another account");
       }
+
+      // The type of the value in the response that is returned by QueryChanges depends on the
+      // number of queries that is provided as input. If a single query is provided as input the
+      // value type is {@code List<ChangeInfo>}, if multiple queries are provided as input the value
+      // type is {@code List<List<ChangeInfo>>) (one {@code List<ChangeInfo>} as result to each
+      // query). Since in this case we provide exactly one query ("has:stars") as input we know that
+      // the value always has the type {@code List<ChangeInfo>} and hence we can safely cast the
+      // value to this type.
       QueryChanges query = changes.list();
       query.addQuery("has:stars");
-      return (List<ChangeInfo>) query.apply(TopLevelResource.INSTANCE);
+      Response<?> response = query.apply(TopLevelResource.INSTANCE);
+      List<ChangeInfo> value = (List<ChangeInfo>) response.value();
+      return Response.ok(value);
     }
   }
 
@@ -120,11 +130,12 @@
     }
 
     @Override
-    public SortedSet<String> apply(AccountResource.Star rsrc) throws AuthException {
+    public Response<SortedSet<String>> apply(AccountResource.Star rsrc) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to get stars of another account");
       }
-      return starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId());
+      return Response.ok(
+          starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId()));
     }
   }
 
@@ -140,18 +151,19 @@
     }
 
     @Override
-    public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
+    public Response<Collection<String>> apply(AccountResource.Star rsrc, StarsInput in)
         throws AuthException, BadRequestException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to update stars of another account");
       }
       try {
-        return starredChangesUtil.star(
-            self.get().getAccountId(),
-            rsrc.getChange().getProject(),
-            rsrc.getChange().getId(),
-            in.add,
-            in.remove);
+        return Response.ok(
+            starredChangesUtil.star(
+                self.get().getAccountId(),
+                rsrc.getChange().getProject(),
+                rsrc.getChange().getId(),
+                in.add,
+                in.remove));
       } catch (IllegalLabelException e) {
         throw new BadRequestException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index c3327e4..df3b58e 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
@@ -67,7 +68,7 @@
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AbandonInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException,
           ConfigInvalidException {
@@ -84,7 +85,7 @@
             rsrc.getUser(),
             input.message,
             notifyResolver.resolve(notify, input.notifyDetails));
-    return json.noOptions().format(change);
+    return Response.ok(json.noOptions().format(change));
   }
 
   private NotifyHandling defaultNotify(Change change) {
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 085f9de..74c5bc2 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -21,8 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.edit.ChangeEdit;
@@ -39,7 +39,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -73,12 +72,11 @@
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.checkedGet(project);
     PatchSet patchSet = revisionResource.getPatchSet();
-    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       List<TreeModification> treeModifications =
           fixReplacementInterpreter.toTreeModifications(
-              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
               repository, revisionResource.getNotes(), patchSet, treeModifications);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index d4cc034..0adb28e 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -15,6 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -35,9 +39,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
@@ -60,7 +61,6 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -365,9 +365,7 @@
         return Response.ok(
             fileContentUtil.getContent(
                 projectCache.checkedGet(rsrc.getChangeResource().getProject()),
-                base
-                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : edit.getEditCommit(),
+                base ? edit.getBasePatchSet().commitId() : edit.getEditCommit(),
                 rsrc.getPath(),
                 null));
       } catch (ResourceNotFoundException | BadRequestException e) {
@@ -386,22 +384,22 @@
     }
 
     @Override
-    public FileInfo apply(ChangeEditResource rsrc) {
+    public Response<FileInfo> apply(ChangeEditResource rsrc) {
       FileInfo r = new FileInfo();
       ChangeEdit edit = rsrc.getChangeEdit();
       Change change = edit.getChange();
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               change.getProject().get(),
               change.getChangeId(),
-              edit.getBasePatchSet().getPatchSetId(),
-              edit.getBasePatchSet().getRefName(),
+              edit.getBasePatchSet().number(),
+              edit.getBasePatchSet().refName(),
               rsrc.getPath(),
               0,
               edit.getRefName(),
               rsrc.getPath());
       r.webLinks = links.isEmpty() ? null : links;
-      return r;
+      return Response.ok(r);
     }
 
     public static class FileInfo {
@@ -425,7 +423,7 @@
     }
 
     @Override
-    public Object apply(ChangeResource rsrc, Input input)
+    public Response<Object> apply(ChangeResource rsrc, Input input)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       if (input == null || Strings.isNullOrEmpty(input.message)) {
@@ -460,7 +458,7 @@
     }
 
     @Override
-    public BinaryResult apply(ChangeResource rsrc)
+    public Response<BinaryResult> apply(ChangeResource rsrc)
         throws AuthException, IOException, ResourceNotFoundException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       String msg;
@@ -468,18 +466,17 @@
         if (base) {
           try (Repository repo = repoManager.openRepository(rsrc.getProject());
               RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit =
-                rw.parseCommit(
-                    ObjectId.fromString(edit.get().getBasePatchSet().getRevision().get()));
+            RevCommit commit = rw.parseCommit(edit.get().getBasePatchSet().commitId());
             msg = commit.getFullMessage();
           }
         } else {
           msg = edit.get().getEditCommit().getFullMessage();
         }
 
-        return BinaryResult.create(msg)
-            .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-            .base64();
+        return Response.ok(
+            BinaryResult.create(msg)
+                .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+                .base64());
       }
       throw new ResourceNotFoundException();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
index 6ec4fdb..67b5870 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.IncludedIn;
@@ -37,8 +38,8 @@
   }
 
   @Override
-  public IncludedInInfo apply(ChangeResource rsrc) throws RestApiException, IOException {
+  public Response<IncludedInInfo> apply(ChangeResource rsrc) throws RestApiException, IOException {
     PatchSet ps = psUtil.current(rsrc.getNotes());
-    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
+    return Response.ok(includedIn.apply(rsrc.getProject(), ps.commitId().name()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
index 96c517f..fae9180 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -50,11 +49,10 @@
   }
 
   @Override
-  public ChangeMessageResource parse(ChangeResource parent, IdString id)
-      throws ResourceNotFoundException, PermissionBackendException {
+  public ChangeMessageResource parse(ChangeResource parent, IdString id) throws Exception {
     String uuid = id.get();
 
-    List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent);
+    List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent).value();
     int index = -1;
     for (int i = 0; i < changeMessages.size(); ++i) {
       ChangeMessageInfo changeMessage = changeMessages.get(i);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 028eb52..3b0321d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -23,8 +25,6 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 72781c3..1a89935 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -17,14 +17,15 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -75,14 +76,12 @@
   }
 
   @Override
-  public CherryPickChangeInfo applyImpl(
+  public Response<CherryPickChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
     input.parent = input.parent == null ? 1 : input.parent;
-    if (input.message == null || input.message.trim().isEmpty()) {
-      throw new BadRequestException("message must be non-empty");
-    } else if (input.destination == null || input.destination.trim().isEmpty()) {
+    if (input.destination == null || input.destination.trim().isEmpty()) {
       throw new BadRequestException("destination must be non-empty");
     }
 
@@ -103,13 +102,13 @@
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
-              new Branch.NameKey(rsrc.getProject(), refName));
+              BranchNameKey.create(rsrc.getProject(), refName));
       CherryPickChangeInfo changeInfo =
           json.noOptions()
               .format(rsrc.getProject(), cherryPickResult.changeId(), CherryPickChangeInfo::new);
       changeInfo.containsGitConflicts =
           !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
-      return changeInfo;
+      return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 132310d..407c560 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -21,6 +21,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,11 +33,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -136,30 +136,116 @@
     this.notifyResolver = notifyResolver;
   }
 
+  /**
+   * This function is used for cherry picking a change.
+   *
+   * @param batchUpdateFactory Used for applying changes to the database.
+   * @param change Change to cherry pick.
+   * @param patch The patch of that change.
+   * @param input Input object for different configurations of cherry pick.
+   * @param dest Destination branch for the cherry pick.
+   * @return Result object that describes the cherry pick.
+   * @throws IOException Unable to open repository or read from the database.
+   * @throws InvalidChangeOperationException Parent or branch don't exist, or two changes with same
+   *     key exist in the branch.
+   * @throws IntegrationException Merge conflict or trees are identical after cherry pick.
+   * @throws UpdateException Problem updating the database using batchUpdateFactory.
+   * @throws RestApiException Error such as invalid SHA1
+   * @throws ConfigInvalidException Can't find account to notify.
+   * @throws NoSuchProjectException Can't find project state.
+   */
   public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       Change change,
       PatchSet patch,
       CherryPickInput input,
-      Branch.NameKey dest)
+      BranchNameKey dest)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
         batchUpdateFactory,
         change,
         change.getProject(),
-        ObjectId.fromString(patch.getRevision().get()),
+        patch.commitId(),
         input,
-        dest);
+        dest,
+        null,
+        null,
+        null);
   }
 
+  /**
+   * This function is called directly to cherry pick a commit. Also, it is used to cherry pick a
+   * change as well as long as sourceChange is not null.
+   *
+   * @param batchUpdateFactory Used for applying changes to the database.
+   * @param sourceChange Change to cherry pick. Can be null, and then the function will only cherry
+   *     pick a commit.
+   * @param project Project name
+   * @param sourceCommit Id of the commit to be cherry picked.
+   * @param input Input object for different configurations of cherry pick.
+   * @param dest Destination branch for the cherry pick.
+   * @return Result object that describes the cherry pick.
+   * @throws IOException Unable to open repository or read from the database.
+   * @throws InvalidChangeOperationException Parent or branch don't exist, or two changes with same
+   *     key exist in the branch.
+   * @throws IntegrationException Merge conflict or trees are identical after cherry pick.
+   * @throws UpdateException Problem updating the database using batchUpdateFactory.
+   * @throws RestApiException Error such as invalid SHA1
+   * @throws ConfigInvalidException Can't find account to notify.
+   * @throws NoSuchProjectException Can't find project state.
+   */
   public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
       Project.NameKey project,
       ObjectId sourceCommit,
       CherryPickInput input,
-      Branch.NameKey dest)
+      BranchNameKey dest)
+      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
+          RestApiException, ConfigInvalidException, NoSuchProjectException {
+    return cherryPick(
+        batchUpdateFactory, sourceChange, project, sourceCommit, input, dest, null, null, null);
+  }
+
+  /**
+   * This function can be called directly to cherry-pick a change (or commit if sourceChange is
+   * null) with a few other parameters that are especially useful for cherry-picking a commit that
+   * is the revert-of another change.
+   *
+   * @param batchUpdateFactory Used for applying changes to the database.
+   * @param sourceChange Change to cherry pick. Can be null, and then the function will only cherry
+   *     pick a commit.
+   * @param project Project name
+   * @param sourceCommit Id of the commit to be cherry picked.
+   * @param input Input object for different configurations of cherry pick.
+   * @param dest Destination branch for the cherry pick.
+   * @param topic Topic name for the change created.
+   * @param revertedChange The id of the change that is reverted. This is used for the "revertOf"
+   *     field to mark the created cherry pick change as "revertOf" the original change that was
+   *     reverted.
+   * @param changeIdForNewChange The Change-Id that the new change that of the cherry pick will
+   *     have.
+   * @return Result object that describes the cherry pick.
+   * @throws IOException Unable to open repository or read from the database.
+   * @throws InvalidChangeOperationException Parent or branch don't exist, or two changes with same
+   *     key exist in the branch.
+   * @throws IntegrationException Merge conflict or trees are identical after cherry pick.
+   * @throws UpdateException Problem updating the database using batchUpdateFactory.
+   * @throws RestApiException Error such as invalid SHA1
+   * @throws ConfigInvalidException Can't find account to notify.
+   * @throws NoSuchProjectException Can't find project state.
+   */
+  public Result cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
+      @Nullable Change sourceChange,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      CherryPickInput input,
+      BranchNameKey dest,
+      @Nullable String topic,
+      @Nullable Change.Id revertedChange,
+      @Nullable ObjectId changeIdForNewChange)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
 
@@ -171,10 +257,10 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef == null) {
         throw new InvalidChangeOperationException(
-            String.format("Branch %s does not exist.", dest.get()));
+            String.format("Branch %s does not exist.", dest.branch()));
       }
 
       RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
@@ -189,16 +275,22 @@
                 input.parent, commitToCherryPick.getParentCount()));
       }
 
+      String message = Strings.nullToEmpty(input.message).trim();
+      message = message.isEmpty() ? commitToCherryPick.getFullMessage() : message;
+
       Timestamp now = TimeUtil.nowTs();
       PersonIdent committerIdent = identifiedUser.newCommitterIdent(now, serverTimeZone);
 
-      final ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
-      String commitMessage = ChangeIdUtil.insertId(input.message, generatedChangeId).trim() + '\n';
+      final ObjectId generatedChangeId =
+          changeIdForNewChange != null
+              ? changeIdForNewChange
+              : CommitMessageUtil.generateChangeId();
+      String commitMessage = ChangeIdUtil.insertId(message, generatedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      ProjectState projectState = projectCache.checkedGet(dest.getParentKey());
+      ProjectState projectState = projectCache.checkedGet(dest.project());
       if (projectState == null) {
-        throw new NoSuchProjectException(dest.getParentKey());
+        throw new NoSuchProjectException(dest.project());
       }
       try {
         MergeUtil mergeUtil;
@@ -226,12 +318,12 @@
         final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
         if (!idList.isEmpty()) {
           final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = new Change.Key(idStr);
+          changeKey = Change.key(idStr);
         } else {
-          changeKey = new Change.Key("I" + generatedChangeId.name());
+          changeKey = Change.key("I" + generatedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
+        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -252,13 +344,22 @@
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
-            String newTopic = null;
-            if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
+            String newTopic = topic;
+            if (topic == null
+                && sourceChange != null
+                && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+              newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
             }
             changeId =
                 createNewChange(
-                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
+                    bu,
+                    cherryPickCommit,
+                    dest.branch(),
+                    newTopic,
+                    sourceChange,
+                    sourceCommit,
+                    input,
+                    revertedChange);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -337,15 +438,20 @@
       String refName,
       String topic,
       @Nullable Change sourceChange,
-      ObjectId sourceCommit,
-      CherryPickInput input)
+      @Nullable ObjectId sourceCommit,
+      CherryPickInput input,
+      @Nullable Change.Id revertOf)
       throws IOException {
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
+    Change.Id changeId = Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
-    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    ins.setRevertOf(revertOf);
+    BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     ins.setMessage(
-            messageForDestinationChange(
-                ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit))
+            revertOf == null
+                ? messageForDestinationChange(
+                    ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit)
+                : "Uploaded patch set 1.") // For revert commits, the message should not include
+        // cherry-pick information.
         .setTopic(topic)
         .setWorkInProgress(
             (sourceChange != null && sourceChange.isWorkInProgress())
@@ -373,12 +479,12 @@
 
   private String messageForDestinationChange(
       PatchSet.Id patchSetId,
-      Branch.NameKey sourceBranch,
+      BranchNameKey sourceBranch,
       ObjectId sourceCommit,
       CodeReviewCommit cherryPickCommit) {
     StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
     if (sourceBranch != null) {
-      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.shortName());
     } else {
       stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index f34f178..a3c8a97 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -15,14 +15,15 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -42,7 +43,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class CherryPickCommit
@@ -70,13 +70,10 @@
   }
 
   @Override
-  public CherryPickChangeInfo applyImpl(
+  public Response<CherryPickChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
-    RevCommit commit = rsrc.getCommit();
-    String message = Strings.nullToEmpty(input.message).trim();
-    input.message = message.isEmpty() ? commit.getFullMessage() : message;
     String destination = Strings.nullToEmpty(input.destination).trim();
     input.parent = input.parent == null ? 1 : input.parent;
     Project.NameKey projectName = rsrc.getProjectState().getNameKey();
@@ -100,15 +97,15 @@
               updateFactory,
               null,
               projectName,
-              commit,
+              rsrc.getCommit(),
               input,
-              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
+              BranchNameKey.create(rsrc.getProjectState().getNameKey(), refName));
       CherryPickChangeInfo changeInfo =
           json.noOptions()
               .format(projectName, cherryPickResult.changeId(), CherryPickChangeInfo::new);
       changeInfo.containsGitConflicts =
           !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
-      return changeInfo;
+      return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 7112bbf..03898b1 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -22,6 +22,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -29,10 +33,6 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.FixReplacement;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index d9a0d57..078c239 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.CommentResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -58,7 +58,7 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().getId())) {
+    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new CommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index f626db2..9d65940 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -21,6 +21,13 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -36,11 +43,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -64,6 +66,7 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -79,6 +82,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
@@ -87,6 +91,7 @@
 import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -101,7 +106,7 @@
 @Singleton
 public class CreateChange
     extends RetryingRestCollectionModifyView<
-        TopLevelResource, ChangeResource, ChangeInput, Response<ChangeInfo>> {
+        TopLevelResource, ChangeResource, ChangeInput, ChangeInfo> {
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
@@ -113,6 +118,7 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeJson.Factory jsonFactory;
   private final ChangeFinder changeFinder;
+  private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final MergeUtil.Factory mergeUtilFactory;
   private final SubmitType submitType;
@@ -133,6 +139,7 @@
       ChangeInserter.Factory changeInserterFactory,
       ChangeJson.Factory json,
       ChangeFinder changeFinder,
+      Provider<InternalChangeQuery> queryProvider,
       RetryHelper retryHelper,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
@@ -151,6 +158,7 @@
     this.changeInserterFactory = changeInserterFactory;
     this.jsonFactory = json;
     this.changeFinder = changeFinder;
+    this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
     this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
@@ -164,6 +172,9 @@
       BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
       throws IOException, InvalidChangeOperationException, RestApiException, UpdateException,
           PermissionBackendException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
     IdentifiedUser me = user.get().asIdentifiedUser();
     checkAndSanitizeChangeInput(input, me);
 
@@ -209,6 +220,20 @@
     }
     input.subject = subject;
 
+    Optional<String> changeId = getChangeIdFromMessage(input.subject);
+    if (changeId.isPresent()) {
+      if (!queryProvider
+          .get()
+          .setLimit(1)
+          .byBranchKey(
+              BranchNameKey.create(input.project, input.branch), Change.key(changeId.get()))
+          .isEmpty()) {
+        throw new ResourceConflictException(
+            String.format(
+                "A change with Change-Id %s already exists for this branch.", changeId.get()));
+      }
+    }
+
     if (input.topic != null) {
       input.topic = Strings.emptyToNull(input.topic.trim());
     }
@@ -238,7 +263,7 @@
         input.workInProgress = true;
       } else {
         input.workInProgress =
-            firstNonNull(me.state().getGeneralPreferences().workInProgressByDefault, false);
+            firstNonNull(me.state().generalPreferences().workInProgressByDefault, false);
       }
     }
 
@@ -281,7 +306,7 @@
       if (input.baseChange != null) {
         ChangeNotes baseChange = getBaseChange(input.baseChange);
         basePatchSet = psUtil.current(baseChange);
-        groups = basePatchSet.getGroups();
+        groups = basePatchSet.groups();
       }
       ObjectId parentCommit =
           getParentCommit(
@@ -302,7 +327,7 @@
         c = newCommit(oi, rw, author, mergeTip, commitMessage);
       }
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
       ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       ins.setTopic(input.topic);
@@ -318,7 +343,7 @@
         bu.execute();
       }
       return ins.getChange();
-    } catch (IllegalArgumentException e) {
+    } catch (InvalidMergeStrategyException e) {
       throw new BadRequestException(e.getMessage());
     }
   }
@@ -351,7 +376,7 @@
       throws BadRequestException, IOException, UnprocessableEntityException,
           ResourceConflictException {
     if (basePatchSet != null) {
-      return ObjectId.fromString(basePatchSet.getRevision().get());
+      return basePatchSet.commitId();
     }
 
     Ref destRef = repo.getRefDatabase().exactRef(inputBranch);
@@ -403,6 +428,17 @@
     return parentCommit;
   }
 
+  private Optional<String> getChangeIdFromMessage(String subject) {
+    int indexOfChangeId = ChangeIdUtil.indexOfChangeId(subject, "\n");
+    if (indexOfChangeId == -1) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        subject.substring(
+            indexOfChangeId + 11 /* "Change-Id: "*/,
+            indexOfChangeId + 12 /* "Change-Id: I" */ + Constants.OBJECT_ID_STRING_LENGTH));
+  }
+
   private String getCommitMessage(String subject, IdentifiedUser me) {
     // Add a Change-Id line if there isn't already one
     String commitMessage = subject;
@@ -411,15 +447,14 @@
       commitMessage = ChangeIdUtil.insertId(commitMessage, id);
     }
 
-    if (Boolean.TRUE.equals(me.state().getGeneralPreferences().signedOffBy)) {
+    if (Boolean.TRUE.equals(me.state().generalPreferences().signedOffBy)) {
       commitMessage =
           Joiner.on("\n")
               .join(
                   commitMessage.trim(),
                   String.format(
                       "%s%s",
-                      SIGNED_OFF_BY_TAG,
-                      me.state().getAccount().getNameEmail(anonymousCowardName)));
+                      SIGNED_OFF_BY_TAG, me.state().account().getNameEmail(anonymousCowardName)));
     }
 
     return commitMessage;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index b6e7628..f434e31 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -25,9 +27,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -48,7 +47,7 @@
 
 @Singleton
 public class CreateDraftComment
-    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
+    extends RetryingRestModifyView<RevisionResource, DraftInput, CommentInfo> {
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -84,7 +83,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getPatchSet().getId(), in);
+      Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
@@ -115,13 +114,14 @@
 
       comment =
           commentsUtil.newComment(
-              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+              ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
 
-      commentsUtil.putComments(ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
+      commentsUtil.putComments(
+          ctx.getUpdate(psId), Comment.Status.DRAFT, Collections.singleton(comment));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index b7bcaf9..a159486 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -17,6 +17,11 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.MergeInput;
@@ -28,10 +33,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -76,7 +77,7 @@
 
 @Singleton
 public class CreateMergePatchSet
-    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
+    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, ChangeInfo> {
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
   private final TimeZone serverTimeZone;
@@ -138,7 +139,7 @@
     PatchSet ps = psUtil.current(rsrc.getNotes());
     Change change = rsrc.getChange();
     Project.NameKey project = change.getProject();
-    Branch.NameKey dest = change.getDest();
+    BranchNameKey dest = change.getDest();
     try (Repository git = gitManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
@@ -154,10 +155,10 @@
       List<String> groups = null;
       if (!in.inheritParent && !in.baseChange.isEmpty()) {
         PatchSet basePS = findBasePatchSet(in.baseChange);
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(basePS.getRevision().get()));
-        groups = basePS.getGroups();
+        currentPsCommit = rw.parseCommit(basePS.commitId());
+        groups = basePS.groups();
       } else {
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
       Timestamp now = TimeUtil.nowTs();
@@ -176,7 +177,7 @@
               author,
               ObjectId.fromString(change.getKey().get().substring(1)));
 
-      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
           patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
       try (BatchUpdate bu = updateFactory.create(project, me, now)) {
@@ -194,6 +195,8 @@
 
       ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
       return Response.ok(json.format(psInserter.getChange()));
+    } catch (InvalidMergeStrategyException e) {
+      throw new BadRequestException(e.getMessage());
     }
   }
 
@@ -215,7 +218,7 @@
   private RevCommit createMergeCommit(
       MergePatchSetInput in,
       ProjectState projectState,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository git,
       ObjectInserter oi,
       RevWalk rw,
@@ -234,7 +237,7 @@
       parentCommit = currentPsCommit.getId();
     } else {
       // get the current branch tip of destination branch
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef != null) {
         parentCommit = destRef.getObjectId();
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 02387be..834782f 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -42,8 +42,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteAssignee
-    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
+public class DeleteAssignee extends RetryingRestModifyView<ChangeResource, Input, AccountInfo> {
 
   private final ChangeMessagesUtil cmUtil;
   private final AssigneeChanged assigneeChanged;
@@ -105,7 +104,7 @@
     }
 
     public Account.Id getDeletedAssignee() {
-      return deletedAssignee != null ? deletedAssignee.getAccount().getId() : null;
+      return deletedAssignee != null ? deletedAssignee.account().id() : null;
     }
 
     private void addMessage(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 3021d81..aa4dcf0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -16,12 +16,12 @@
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.DeleteChangeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -36,7 +36,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Object>
     implements UiAction<ChangeResource> {
 
   private final DeleteChangeOp.Factory opFactory;
@@ -48,7 +48,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     if (!isChangeDeletable(rsrc)) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 0fb8e18..30cfad6 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -20,14 +20,14 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -53,7 +53,7 @@
 @Singleton
 public class DeleteChangeMessage
     extends RetryingRestModifyView<
-        ChangeMessageResource, DeleteChangeMessageInput, Response<ChangeMessageInfo>> {
+        ChangeMessageResource, DeleteChangeMessageInput, ChangeMessageInfo> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
@@ -146,7 +146,7 @@
 
   @Singleton
   public static class DefaultDeleteChangeMessage
-      extends RetryingRestModifyView<ChangeMessageResource, Input, Response<ChangeMessageInfo>> {
+      extends RetryingRestModifyView<ChangeMessageResource, Input, ChangeMessageInfo> {
     private final DeleteChangeMessage deleteChangeMessage;
 
     @Inject
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 30a8efd7..95479a6 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.CommentResource;
@@ -71,7 +72,7 @@
   }
 
   @Override
-  public CommentInfo applyImpl(
+  public Response<CommentInfo> applyImpl(
       BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           UpdateException {
@@ -100,7 +101,7 @@
       throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
     }
 
-    return commentJson.get().newCommentFormatter().format(updatedComment.get());
+    return Response.ok(commentJson.get().newCommentFormatter().format(updatedComment.get()));
   }
 
   private static String getCommentNewMessage(String name, String reason) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index de04d36..9296988 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
@@ -42,7 +42,7 @@
 
 @Singleton
 public class DeleteDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
+    extends RetryingRestModifyView<DraftCommentResource, Input, CommentInfo> {
 
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -88,13 +88,13 @@
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), key.patchSetId);
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       Comment c = maybeComment.get();
-      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 8601e68..de7a683 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -36,7 +36,7 @@
 
 @Singleton
 public class DeletePrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String> {
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 12dbcdd..b98bb3b 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
 import com.google.gerrit.server.change.DeleteReviewerOp;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -34,7 +34,7 @@
 
 @Singleton
 public class DeleteReviewer
-    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
+    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Object> {
 
   private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
   private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
@@ -50,7 +50,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 77894fb..1193ad6 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -19,6 +19,11 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,11 +32,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -64,7 +64,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
+public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Object> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ApprovalsUtil approvalsUtil;
@@ -102,7 +102,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
       throws RestApiException, UpdateException, IOException, ConfigInvalidException {
     if (input == null) {
@@ -170,16 +170,16 @@
       boolean found = false;
       LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
 
-      Account.Id accountId = accountState.getAccount().getId();
+      Account.Id accountId = accountState.account().id();
 
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.getLabelId()) == null) {
+        if (labelTypes.byLabel(a.labelId()) == null) {
           continue; // Ignore undefined labels.
-        } else if (!a.getLabel().equals(label)) {
+        } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.getLabel(), a.getValue());
+          newApprovals.put(a.label(), a.value());
           continue;
         } else {
           try {
@@ -189,11 +189,11 @@
           }
         }
         // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.getLabel(), (short) 0);
+        newApprovals.put(a.label(), (short) 0);
         found = true;
 
         // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.getLabel(), a.getValue());
+        oldApprovals.put(a.label(), a.value());
         break;
       }
       if (!found) {
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index 1022cad..4a4a680 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
@@ -24,7 +25,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Option;
 
 public class DownloadContent implements RestReadView<FileResource> {
@@ -41,12 +41,12 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
+  public Response<BinaryResult> apply(FileResource rsrc)
       throws ResourceNotFoundException, IOException, NoSuchChangeException {
-    String path = rsrc.getPatchKey().get();
+    String path = rsrc.getPatchKey().fileName();
     RevisionResource rev = rsrc.getRevision();
-    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
-    return fileContentUtil.downloadContent(
-        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
+    return Response.ok(
+        fileContentUtil.downloadContent(
+            projectCache.checkedGet(rev.getProject()), rev.getPatchSet().commitId(), path, parent));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index dd61ca0..bab1ac9 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.DraftCommentResource;
@@ -66,7 +66,7 @@
     String uuid = id.get();
     for (Comment c :
         commentsUtil.draftByPatchSetAuthor(
-            rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
+            rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index d892810..392aef7 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -19,6 +19,10 @@
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -31,10 +35,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
@@ -63,7 +63,6 @@
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -166,13 +165,13 @@
             Response.ok(
                 fileInfoJson.toFileInfoMap(
                     resource.getChange(),
-                    resource.getPatchSet().getRevision(),
+                    resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
       } else if (parentNum != 0) {
         int parents =
             gApi.changes()
                 .id(resource.getChange().getChangeId())
-                .revision(resource.getPatchSet().getId().get())
+                .revision(resource.getPatchSet().id().get())
                 .commit(false)
                 .parents
                 .size();
@@ -182,7 +181,7 @@
         r =
             Response.ok(
                 fileInfoJson.toFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
         r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -219,8 +218,7 @@
           ObjectReader or = git.newObjectReader();
           RevWalk rw = new RevWalk(or);
           TreeWalk tw = new TreeWalk(or)) {
-        RevCommit c =
-            rw.parseCommit(ObjectId.fromString(resource.getPatchSet().getRevision().get()));
+        RevCommit c = rw.parseCommit(resource.getPatchSet().commitId());
 
         tw.addTree(c.getTree());
         tw.setRecursive(true);
@@ -244,11 +242,11 @@
       Account.Id userId = user.getAccountId();
       PatchSet patchSetId = resource.getPatchSet();
       Optional<PatchSetWithReviewedFiles> o;
-      o = accountPatchReviewStore.call(s -> s.findReviewed(patchSetId.getId(), userId));
+      o = accountPatchReviewStore.call(s -> s.findReviewed(patchSetId.id(), userId));
 
       if (o.isPresent()) {
         PatchSetWithReviewedFiles res = o.get();
-        if (res.patchSetId().equals(patchSetId.getId())) {
+        if (res.patchSetId().equals(patchSetId.id())) {
           return res.files();
         }
 
@@ -327,7 +325,7 @@
         }
 
         accountPatchReviewStore.run(
-            s -> s.markReviewed(resource.getPatchSet().getId(), userId, pathList));
+            s -> s.markReviewed(resource.getPatchSet().id(), userId, pathList));
         return pathList;
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
index 855d1f4..38240e3 100644
--- a/java/com/google/gerrit/server/restapi/change/Fixes.java
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -54,7 +54,7 @@
     ChangeNotes changeNotes = revisionResource.getNotes();
 
     List<RobotComment> robotComments =
-        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id());
     for (RobotComment robotComment : robotComments) {
       for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
         if (Objects.equals(fixId, fixSuggestion.fixId)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetArchive.java b/java/com/google/gerrit/server/restapi/change/GetArchive.java
index 1bd1bce..4ebcbdd 100644
--- a/java/com/google/gerrit/server/restapi/change/GetArchive.java
+++ b/java/com/google/gerrit/server/restapi/change/GetArchive.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.gerrit.server.change.RevisionResource;
@@ -27,7 +30,6 @@
 import java.io.OutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -47,7 +49,7 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
+  public Response<BinaryResult> apply(RevisionResource rsrc)
       throws BadRequestException, IOException, MethodNotAllowedException {
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
@@ -65,7 +67,7 @@
       final RevCommit commit;
       String name;
       try (RevWalk rw = new RevWalk(repo)) {
-        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         name = name(f, rw, commit);
       }
 
@@ -93,7 +95,7 @@
       bin.disableGzip().setContentType(f.getMimeType()).setAttachmentName(name);
 
       close = false;
-      return bin;
+      return Response.ok(bin);
     } finally {
       if (close) {
         repo.close();
@@ -104,6 +106,6 @@
   private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
       throws IOException {
     return String.format(
-        "%s%s", rw.getObjectReader().abbreviate(commit, 7).name(), format.getDefaultSuffix());
+        "%s%s", abbreviateName(commit, rw.getObjectReader()), format.getDefaultSuffix());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
index f89fe1b..a5820bf 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index 98a3a8a..12b4d44 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.BlameInfo;
 import com.google.gerrit.extensions.common.RangeInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
@@ -23,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -92,7 +92,7 @@
       String refName =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
       Ref ref = repository.findRef(refName);
       if (ref == null) {
@@ -102,7 +102,7 @@
       RevCommit revCommit = revWalk.parseCommit(objectId);
       RevCommit[] parents = revCommit.getParents();
 
-      String path = resource.getPatchKey().getFileName();
+      String path = resource.getPatchKey().fileName();
 
       List<BlameInfo> result;
       if (!base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java b/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
index f55785d..9e0e0e3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 @Singleton
 public class GetChangeMessage implements RestReadView<ChangeMessageResource> {
   @Override
-  public ChangeMessageInfo apply(ChangeMessageResource resource) {
-    return resource.getChangeMessage();
+  public Response<ChangeMessageInfo> apply(ChangeMessageResource resource) {
+    return Response.ok(resource.getChangeMessage());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index 0109c95..5103325 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.CommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public CommentInfo apply(CommentResource rsrc) throws PermissionBackendException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  public Response<CommentInfo> apply(CommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 29286cb..21a08dc 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -55,8 +54,7 @@
     Project.NameKey p = rsrc.getChange().getProject();
     try (Repository repo = repoManager.openRepository(p);
         RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java
index 1d35ab5..bf7c51f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
@@ -33,7 +34,6 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -61,25 +61,28 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
+  public Response<BinaryResult> apply(FileResource rsrc)
       throws ResourceNotFoundException, IOException, BadRequestException {
-    String path = rsrc.getPatchKey().get();
+    String path = rsrc.getPatchKey().fileName();
     if (Patch.COMMIT_MSG.equals(path)) {
       String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(msg)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-          .base64();
+      return Response.ok(
+          BinaryResult.create(msg)
+              .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+              .base64());
     } else if (Patch.MERGE_LIST.equals(path)) {
       byte[] mergeList = getMergeList(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(mergeList)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
-          .base64();
+      return Response.ok(
+          BinaryResult.create(mergeList)
+              .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
+              .base64());
     }
-    return fileContentUtil.getContent(
-        projectCache.checkedGet(rsrc.getRevision().getProject()),
-        ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
-        path,
-        parent);
+    return Response.ok(
+        fileContentUtil.getContent(
+            projectCache.checkedGet(rsrc.getRevision().getProject()),
+            rsrc.getRevision().getPatchSet().commitId(),
+            path,
+            parent));
   }
 
   private String getMessage(ChangeNotes notes) throws IOException {
@@ -91,7 +94,7 @@
 
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = revWalk.parseCommit(ps.commitId());
       return commit.getFullMessage();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
@@ -108,9 +111,7 @@
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
       return Text.forMergeList(
-              ComparisonType.againstAutoMerge(),
-              revWalk.getObjectReader(),
-              ObjectId.fromString(ps.getRevision().get()))
+              ComparisonType.againstAutoMerge(), revWalk.getObjectReader(), ps.commitId())
           .getContent();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDescription.java b/java/com/google/gerrit/server/restapi/change/GetDescription.java
index 1a7ec63..6794d81 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDescription.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.Singleton;
@@ -22,7 +22,7 @@
 @Singleton
 public class GetDescription implements RestReadView<RevisionResource> {
   @Override
-  public String apply(RevisionResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+  public Response<String> apply(RevisionResource rsrc) {
+    return Response.ok(rsrc.getPatchSet().description().orElse(""));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 6a7f1fa..39c5a3b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -25,6 +25,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -43,9 +46,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.jgit.diff.ReplaceEdit;
 import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
@@ -140,14 +140,14 @@
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
-    PatchSet.Id pId = resource.getPatchKey().getParentKey();
-    String fileName = resource.getPatchKey().getFileName();
+    PatchSet.Id pId = resource.getPatchKey().patchSetId();
+    String fileName = resource.getPatchKey().fileName();
     ChangeNotes notes = resource.getRevision().getNotes();
     if (base != null) {
       RevisionResource baseResource =
           revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
     } else if (parentNum > 0) {
       psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
     } else {
@@ -195,20 +195,20 @@
       ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
 
       DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
+      String revA = basePatchSet != null ? basePatchSet.refName() : content.commitIdA;
       String revB =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               state.getName(),
-              resource.getPatchKey().getParentKey().getParentKey().get(),
-              basePatchSet != null ? basePatchSet.getId().get() : null,
+              resource.getPatchKey().patchSetId().changeId().get(),
+              basePatchSet != null ? basePatchSet.id().get() : null,
               revA,
               MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-              resource.getPatchKey().getParentKey().get(),
+              resource.getPatchKey().patchSetId().get(),
               revB,
               ps.getNewName());
       result.webLinks = links.isEmpty() ? null : links;
@@ -271,7 +271,7 @@
   }
 
   private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
-    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
+    ImmutableList<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
     return links.isEmpty() ? null : links;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index ca5b56f..797dc9e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public CommentInfo apply(DraftCommentResource rsrc) throws PermissionBackendException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  public Response<CommentInfo> apply(DraftCommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c18a8f..0c67fd6 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -16,12 +16,12 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -31,7 +31,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -67,8 +66,7 @@
     Project.NameKey p = rsrc.getChange().getProject();
     try (Repository repo = repoManager.openRepository(p);
         RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
 
       if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
index 1d56669..c1c9a34 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
@@ -16,10 +16,10 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index b205ece..187ebce 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -31,8 +33,6 @@
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.lib.AbbreviatedObjectId;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -60,15 +60,15 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
+  public Response<BinaryResult> apply(RevisionResource rsrc)
       throws ResourceConflictException, IOException, ResourceNotFoundException {
     final Repository repo = repoManager.openRepository(rsrc.getProject());
     boolean close = true;
     try {
       final RevWalk rw = new RevWalk(repo);
+      BinaryResult bin = null;
       try {
-        final RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
         if (parents.length > 1) {
           throw new ResourceConflictException("Revision has more than 1 parent.");
@@ -78,7 +78,7 @@
         final RevCommit base = parents[0];
         rw.parseBody(base);
 
-        BinaryResult bin =
+        bin =
             new BinaryResult() {
               @Override
               public void writeTo(OutputStream out) throws IOException {
@@ -132,10 +132,13 @@
         }
 
         close = false;
-        return bin;
+        return Response.ok(bin);
       } finally {
         if (close) {
           rw.close();
+          if (bin != null) {
+            bin.close();
+          }
         }
       }
     } finally {
@@ -189,7 +192,6 @@
   }
 
   private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
-    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
-    return id.name() + ".diff";
+    return abbreviateName(commit, rw.getObjectReader()) + ".diff";
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
index fa5cc36..765be5f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PureRevert;
@@ -46,9 +47,9 @@
   }
 
   @Override
-  public PureRevertInfo apply(ChangeResource rsrc)
+  public Response<PureRevertInfo> apply(ChangeResource rsrc)
       throws ResourceConflictException, IOException, BadRequestException, AuthException {
     boolean isPureRevert = pureRevert.get(rsrc.getNotes(), Optional.ofNullable(claimedOriginal));
-    return new PureRevertInfo(isPureRevert);
+    return Response.ok(new PureRevertInfo(isPureRevert));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 332cc4d..a846d50 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -19,14 +19,15 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.PatchSetUtil;
@@ -68,12 +69,12 @@
   }
 
   @Override
-  public RelatedChangesInfo apply(RevisionResource rsrc)
+  public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
       throws RepositoryNotFoundException, IOException, NoSuchProjectException,
           PermissionBackendException {
     RelatedChangesInfo relatedChangesInfo = new RelatedChangesInfo();
     relatedChangesInfo.changes = getRelated(rsrc);
-    return relatedChangesInfo;
+    return Response.ok(relatedChangesInfo);
   }
 
   private List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
@@ -102,7 +103,7 @@
     for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
-      if (isEdit && ps.getId().equals(basePs.getId())) {
+      if (isEdit && ps.id().equals(basePs.id())) {
         // Replace base of an edit with the edit itself.
         ps = rsrc.getPatchSet();
         commit = rsrc.getEdit().get().getEditCommit();
@@ -114,7 +115,7 @@
 
     if (result.size() == 1) {
       RelatedChangeAndCommitInfo r = result.get(0);
-      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().commitId().name())) {
         return Collections.emptyList();
       }
     }
@@ -123,13 +124,13 @@
 
   @VisibleForTesting
   public static Set<String> getAllGroups(ChangeNotes notes, PatchSetUtil psUtil) {
-    return psUtil.byChange(notes).stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet());
+    return psUtil.byChange(notes).stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
   }
 
   private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) {
     for (ChangeData cd : cds) {
-      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
-        if (cd.patchSet(wantedPs.getId()) == null) {
+      if (cd.getId().equals(wantedPs.id().changeId())) {
+        if (cd.patchSet(wantedPs.id()) == null) {
           cd.reloadChange();
         }
       }
@@ -144,7 +145,7 @@
     if (change != null) {
       info.changeId = change.getKey().get();
       info._changeNumber = change.getChangeId();
-      info._revisionNumber = ps != null ? ps.getPatchSetId() : null;
+      info._revisionNumber = ps != null ? ps.number() : null;
       PatchSet.Id curr = change.currentPatchSetId();
       info._currentRevisionNumber = curr != null ? curr.get() : null;
       info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
diff --git a/java/com/google/gerrit/server/restapi/change/GetReviewer.java b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
index 73760da..a672b176 100644
--- a/java/com/google/gerrit/server/restapi/change/GetReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -33,7 +34,8 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc) throws PermissionBackendException {
-    return json.format(rsrc);
+  public Response<List<ReviewerInfo>> apply(ReviewerResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(json.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
index 75d994d..4ff9942 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RobotCommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +34,8 @@
   }
 
   @Override
-  public RobotCommentInfo apply(RobotCommentResource rsrc) throws PermissionBackendException {
-    return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
+  public Response<RobotCommentInfo> apply(RobotCommentResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(commentJson.get().newRobotCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetTopic.java b/java/com/google/gerrit/server/restapi/change/GetTopic.java
index 7ab1cb1..6951fa5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/GetTopic.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetTopic implements RestReadView<ChangeResource> {
   @Override
-  public String apply(ChangeResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getChange().getTopic());
+  public Response<String> apply(ChangeResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getChange().getTopic()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Index.java b/java/com/google/gerrit/server/restapi/change/Index.java
index 90dad98..5a17c07 100644
--- a/java/com/google/gerrit/server/restapi/change/Index.java
+++ b/java/com/google/gerrit/server/restapi/change/Index.java
@@ -30,7 +30,7 @@
 import java.io.IOException;
 
 @Singleton
-public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
+public class Index extends RetryingRestModifyView<ChangeResource, Input, Object> {
   private final PermissionBackend permissionBackend;
   private final ChangeIndexer indexer;
 
@@ -42,7 +42,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws IOException, AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.MAINTAIN_SERVER);
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index 403922d..edd6201 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.query.change.ChangeData;
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index f8fda73..cc35a5e 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -59,12 +60,12 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
+  public Response<Map<String, List<CommentInfo>>> apply(ChangeResource rsrc)
       throws AuthException, PermissionBackendException {
     if (requireAuthentication() && !rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return getCommentFormatter().format(listComments(rsrc));
+    return Response.ok(getCommentFormatter().format(listComments(rsrc)));
   }
 
   public List<CommentInfo> getComments(ChangeResource rsrc)
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index a2e3d4b..bfc9f12 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -16,9 +16,10 @@
 
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
@@ -41,13 +42,14 @@
   }
 
   @Override
-  public List<ChangeMessageInfo> apply(ChangeResource resource) throws PermissionBackendException {
+  public Response<List<ChangeMessageInfo>> apply(ChangeResource resource)
+      throws PermissionBackendException {
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
             .map(m -> createChangeMessageInfo(m, accountLoader))
             .collect(Collectors.toList());
     accountLoader.fill();
-    return messageInfos;
+    return Response.ok(messageInfos);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index e5840fd..719a477 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -42,14 +43,15 @@
   }
 
   @Override
-  public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
+  public Response<Map<String, List<RobotCommentInfo>>> apply(ChangeResource rsrc)
       throws AuthException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .setFillPatchSet(true)
-        .newRobotCommentFormatter()
-        .format(commentsUtil.robotCommentsByChange(cd.notes()));
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(true)
+            .setFillPatchSet(true)
+            .newRobotCommentFormatter()
+            .format(commentsUtil.robotCommentsByChange(cd.notes())));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 12732ff..25ef480 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
@@ -44,7 +45,7 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc) throws PermissionBackendException {
+  public Response<List<ReviewerInfo>> apply(ChangeResource rsrc) throws PermissionBackendException {
     Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     for (Account.Id accountId : approvalsUtil.getReviewers(rsrc.getNotes()).all()) {
       if (!reviewers.containsKey(accountId.toString())) {
@@ -56,6 +57,6 @@
         reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
-    return json.format(reviewers.values());
+    return Response.ok(json.format(reviewers.values()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index b39ba63..de05d2a 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -37,6 +37,6 @@
   @Override
   protected Iterable<Comment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
-    return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().getId());
+    return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index a46bd6c..199a752 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -40,7 +41,7 @@
 
   protected Iterable<Comment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
-        rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
+        rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
 
   protected boolean includeAuthorInfo() {
@@ -48,13 +49,14 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc)
+  public Response<Map<String, List<CommentInfo>>> apply(RevisionResource rsrc)
       throws PermissionBackendException {
-    return commentJson
-        .get()
-        .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
-        .format(listComments(rsrc));
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(includeAuthorInfo())
+            .newCommentFormatter()
+            .format(listComments(rsrc)));
   }
 
   public ImmutableList<CommentInfo> getComments(RevisionResource rsrc)
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index 920cde9..73b1f59 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -45,7 +46,7 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(RevisionResource rsrc)
+  public Response<List<ReviewerInfo>> apply(RevisionResource rsrc)
       throws MethodNotAllowedException, PermissionBackendException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
@@ -62,6 +63,6 @@
         reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
-    return json.format(reviewers.values());
+    return Response.ok(json.format(reviewers.values()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index bbf46a3..bbbe12d 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -39,13 +40,14 @@
   }
 
   @Override
-  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc)
+  public Response<Map<String, List<RobotCommentInfo>>> apply(RevisionResource rsrc)
       throws PermissionBackendException {
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .newRobotCommentFormatter()
-        .format(listComments(rsrc));
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(true)
+            .newRobotCommentFormatter()
+            .format(listComments(rsrc)));
   }
 
   public ImmutableList<RobotCommentInfo> getComments(RevisionResource rsrc)
@@ -58,6 +60,6 @@
   }
 
   private Iterable<RobotComment> listComments(RevisionResource rsrc) {
-    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
+    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index ece8938..9b17ed6 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
@@ -50,8 +50,6 @@
 import org.kohsuke.args4j.Option;
 
 public class Mergeable implements RestReadView<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   @Option(
       name = "--other-branches",
       aliases = {"-o"},
@@ -89,7 +87,7 @@
   }
 
   @Override
-  public MergeableInfo apply(RevisionResource resource)
+  public Response<MergeableInfo> apply(RevisionResource resource)
       throws AuthException, ResourceConflictException, BadRequestException, IOException {
     Change change = resource.getChange();
     PatchSet ps = resource.getPatchSet();
@@ -97,17 +95,17 @@
 
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ps.getId().equals(change.currentPatchSetId())) {
+    } else if (!ps.id().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
-      return result;
+      return Response.ok(result);
     }
 
     ChangeData cd = changeDataFactory.create(resource.getNotes());
     result.submitType = getSubmitType(cd);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
-      ObjectId commit = toId(ps);
-      Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
+      ObjectId commit = ps.commitId();
+      Ref ref = git.getRefDatabase().exactRef(change.getDest().branch());
       ProjectState projectState = projectCache.get(change.getProject());
       String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
       result.strategy = strategy;
@@ -132,7 +130,7 @@
         }
       }
     }
-    return result;
+    return Response.ok(result);
   }
 
   private SubmitType getSubmitType(ChangeData cd) {
@@ -161,15 +159,6 @@
     return refresh(change, commit, ref, submitType, strategy, git, old);
   }
 
-  private static ObjectId toId(PatchSet ps) {
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Invalid revision on patch set %s", ps);
-      return null;
-    }
-  }
-
   private boolean refresh(
       final Change change,
       ObjectId commit,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index f2335b1..7d4c4d1 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -23,6 +23,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -30,16 +38,9 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -114,7 +115,7 @@
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException {
     if (!moveEnabled) {
@@ -135,7 +136,7 @@
       throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
     }
 
-    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    BranchNameKey newDest = BranchNameKey.create(project, input.destinationBranch);
     if (change.getDest().equals(newDest)) {
       throw new ResourceConflictException("Change is already destined for the specified branch");
     }
@@ -157,14 +158,14 @@
       u.addOp(change.getId(), op);
       u.execute();
     }
-    return json.noOptions().format(op.getChange());
+    return Response.ok(json.noOptions().format(op.getChange()));
   }
 
   private class Op implements BatchUpdateOp {
     private final MoveInput input;
 
     private Change change;
-    private Branch.NameKey newDestKey;
+    private BranchNameKey newDestKey;
 
     Op(MoveInput input) {
       this.input = input;
@@ -183,8 +184,8 @@
       }
 
       Project.NameKey projectKey = change.getProject();
-      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
-      Branch.NameKey changePrevDest = change.getDest();
+      newDestKey = BranchNameKey.create(projectKey, input.destinationBranch);
+      BranchNameKey changePrevDest = change.getDest();
       if (changePrevDest.equals(newDestKey)) {
         throw new ResourceConflictException("Change is already destined for the specified branch");
       }
@@ -193,8 +194,7 @@
       try (Repository repo = repoManager.openRepository(projectKey);
           RevWalk revWalk = new RevWalk(repo)) {
         RevCommit currPatchsetRevCommit =
-            revWalk.parseCommit(
-                ObjectId.fromString(psUtil.current(ctx.getNotes()).getRevision().get()));
+            revWalk.parseCommit(psUtil.current(ctx.getNotes()).commitId());
         if (currPatchsetRevCommit.getParentCount() > 1) {
           throw new ResourceConflictException("Merge commit cannot be moved");
         }
@@ -216,7 +216,7 @@
       if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
         throw new ResourceConflictException(
             "Destination "
-                + newDestKey.getShortName()
+                + newDestKey.shortName()
                 + " has a different change with same change key "
                 + changeKey);
       }
@@ -227,16 +227,16 @@
 
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
-      update.setBranch(newDestKey.get());
+      update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
       updateApprovals(ctx, update, psId, projectKey);
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
-      msgBuf.append(changePrevDest.getShortName());
+      msgBuf.append(changePrevDest.shortName());
       msgBuf.append(" to ");
-      msgBuf.append(newDestKey.getShortName());
+      msgBuf.append(newDestKey.shortName());
       if (!Strings.isNullOrEmpty(input.message)) {
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
@@ -262,7 +262,7 @@
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.checkedGet(project);
-        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.getLabelId());
+        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
@@ -271,12 +271,13 @@
         }
 
         // Remove votes from NoteDb.
-        update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+        update.removeApprovalFor(psa.accountId(), psa.label());
         approvals.add(
-            new PatchSetApproval(
-                new PatchSetApproval.Key(psId, psa.getAccountId(), new LabelId(psa.getLabel())),
-                (short) 0,
-                ctx.getWhen()));
+            PatchSetApproval.builder()
+                .key(PatchSetApproval.key(psId, psa.accountId(), LabelId.create(psa.label())))
+                .value(0)
+                .granted(ctx.getWhen())
+                .build());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index da499a9..516dead 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -33,8 +33,7 @@
 
 @Singleton
 public class PostHashtags
-    extends RetryingRestModifyView<
-        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
+    extends RetryingRestModifyView<ChangeResource, HashtagsInput, ImmutableSortedSet<String>>
     implements UiAction<ChangeResource> {
   private final SetHashtagsOp.Factory hashtagsFactory;
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index 5aa2ecc..f008df3 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -17,13 +17,13 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -39,8 +39,7 @@
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class PostPrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
+public class PostPrivate extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String>
     implements UiAction<ChangeResource> {
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 8c94ba3..e0d0a04 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,7 +16,8 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -29,15 +30,28 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -61,20 +75,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.FixReplacement;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -107,12 +112,14 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
@@ -134,18 +141,21 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class PostReview
-    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
+    extends RetryingRestModifyView<RevisionResource, ReviewInput, ReviewResult> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
+  private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
@@ -172,6 +182,7 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
+  private final PluginSetContext<CommentValidator> commentValidators;
   private final boolean strictLabels;
 
   @Inject
@@ -194,7 +205,8 @@
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      PluginSetContext<CommentValidator> commentValidators) {
     super(retryHelper);
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
@@ -214,6 +226,7 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
+    this.commentValidators = commentValidators;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
@@ -236,7 +249,10 @@
     }
     ProjectState projectState = projectCache.checkedGet(revision.getProject());
     LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
+
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
+    logger.atFine().log("draft handling = %s", input.drafts);
+
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, labelTypes, input);
     }
@@ -244,10 +260,11 @@
       checkLabels(revision, labelTypes, input.labels);
     }
     if (input.comments != null) {
-      cleanUpComments(input.comments);
+      input.comments = cleanUpComments(input.comments);
       checkComments(revision, input.comments);
     }
     if (input.robotComments != null) {
+      input.robotComments = cleanUpComments(input.robotComments);
       checkRobotComments(revision, input.robotComments);
     }
 
@@ -357,8 +374,7 @@
 
       // Add the review op.
       bu.addOp(
-          revision.getChange().getId(),
-          new Op(projectState, revision.getPatchSet().getId(), input));
+          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
       // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
       NotifyResolver.Result notify =
@@ -538,37 +554,30 @@
     }
   }
 
-  private static <T extends CommentInput> void cleanUpComments(
+  private static <T extends CommentInput> Map<String, List<T>> cleanUpComments(
       Map<String, List<T>> commentsPerPath) {
-    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
-    while (mapValueIterator.hasNext()) {
-      List<T> comments = mapValueIterator.next();
+    Map<String, List<T>> cleanedUpCommentMap = new HashMap<>();
+    for (Map.Entry<String, List<T>> e : commentsPerPath.entrySet()) {
+      String path = e.getKey();
+      List<T> comments = e.getValue();
+
       if (comments == null) {
-        mapValueIterator.remove();
         continue;
       }
 
-      cleanUpComments(comments);
-      if (comments.isEmpty()) {
-        mapValueIterator.remove();
+      List<T> cleanedUpComments = cleanUpComments(comments);
+      if (!cleanedUpComments.isEmpty()) {
+        cleanedUpCommentMap.put(path, cleanedUpComments);
       }
     }
+    return cleanedUpCommentMap;
   }
 
-  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
-    Iterator<T> commentsIterator = comments.iterator();
-    while (commentsIterator.hasNext()) {
-      T comment = commentsIterator.next();
-      if (comment == null) {
-        commentsIterator.remove();
-        continue;
-      }
-
-      comment.message = Strings.nullToEmpty(comment.message).trim();
-      if (comment.message.isEmpty()) {
-        commentsIterator.remove();
-      }
-    }
+  private static <T extends CommentInput> List<T> cleanUpComments(List<T> comments) {
+    return comments.stream()
+        .filter(Objects::nonNull)
+        .filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty())
+        .collect(toList());
   }
 
   private <T extends CommentInput> void checkComments(
@@ -577,7 +586,7 @@
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getPatchSet().getId();
+      PatchSet.Id patchSetId = revision.getPatchSet().id();
       ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
 
       List<T> comments = entry.getValue();
@@ -591,7 +600,7 @@
 
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
-    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
+    ObjectId newId = revision.getPatchSet().commitId();
     DiffSummaryKey key =
         DiffSummaryKey.fromPatchListKey(
             PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
@@ -626,7 +635,6 @@
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
-    cleanUpComments(in);
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
@@ -799,7 +807,10 @@
     }
   }
 
-  /** Used to compare Comments with CommentInput comments. */
+  /**
+   * Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
+   * the fields to compare.
+   */
   @AutoValue
   abstract static class CommentSetEntry {
     private static CommentSetEntry create(
@@ -861,12 +872,11 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceConflictException, UnprocessableEntityException, IOException,
-            PatchListNotAvailableException {
+            PatchListNotAvailableException, CommentsRejectedException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
-      boolean dirty = false;
-      dirty |= insertComments(ctx);
+      boolean dirty = insertComments(ctx);
       dirty |= insertRobotComments(ctx);
       dirty |= updateLabels(projectState, ctx);
       dirty |= insertMessage(ctx);
@@ -895,14 +905,19 @@
     }
 
     private boolean insertComments(ChangeContext ctx)
-        throws UnprocessableEntityException, PatchListNotAvailableException {
-      Map<String, List<CommentInput>> map = in.comments;
-      if (map == null) {
-        map = Collections.emptyMap();
+        throws UnprocessableEntityException, PatchListNotAvailableException,
+            CommentsRejectedException {
+      Map<String, List<CommentInput>> inputComments = in.comments;
+      if (inputComments == null) {
+        inputComments = Collections.emptyMap();
       }
 
-      Map<String, Comment> drafts = Collections.emptyMap();
-      if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
+      // object.
+      Map<String, Comment> drafts = new HashMap<>();
+      // If there are inputComments we need the deduplication loop below, so we have to read (and
+      // publish) drafts here.
+      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
         if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
           drafts = changeDrafts(ctx);
         } else {
@@ -910,51 +925,84 @@
         }
       }
 
+      // This will be populated with Comment-s created from inputComments.
       List<Comment> toPublish = new ArrayList<>();
 
-      Set<CommentSetEntry> existingIds =
+      Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
 
-      for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
-        String path = ent.getKey();
-        for (CommentInput c : ent.getValue()) {
-          String parent = Url.decode(c.inReplyTo);
-          Comment e = drafts.remove(Url.decode(c.id));
-          if (e == null) {
-            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
+      // Deduplication:
+      // - Ignore drafts with the same ID as an inputComment here. These are deleted later.
+      // - Swallow comments that already exist.
+      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+        String path = entry.getKey();
+        for (CommentInput inputComment : entry.getValue()) {
+          Comment comment = drafts.remove(Url.decode(inputComment.id));
+          if (comment == null) {
+            String parent = Url.decode(inputComment.inReplyTo);
+            comment =
+                commentsUtil.newComment(
+                    ctx,
+                    path,
+                    psId,
+                    inputComment.side(),
+                    inputComment.message,
+                    inputComment.unresolved,
+                    parent);
           } else {
-            e.writtenOn = ctx.getWhen();
-            e.side = c.side();
-            e.message = c.message;
+            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+            comment.writtenOn = ctx.getWhen();
+            comment.side = inputComment.side();
+            comment.message = inputComment.message;
           }
 
-          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-          e.setLineNbrAndRange(c.line, c.range);
-          e.tag = in.tag;
+          setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+          comment.tag = in.tag;
 
-          if (existingIds.contains(CommentSetEntry.create(e))) {
+          if (existingComments.contains(CommentSetEntry.create(comment))) {
             continue;
           }
-          toPublish.add(e);
+          toPublish.add(comment);
         }
       }
 
       switch (in.drafts) {
         case PUBLISH:
         case PUBLISH_ALL_REVISIONS:
+          validateComments(Streams.concat(drafts.values().stream(), toPublish.stream()));
           publishCommentUtil.publish(ctx, psId, drafts.values(), in.tag);
           comments.addAll(drafts.values());
           break;
         case KEEP:
         default:
+          validateComments(toPublish.stream());
           break;
       }
-      ChangeUpdate u = ctx.getUpdate(psId);
-      commentsUtil.putComments(u, Status.PUBLISHED, toPublish);
+      ChangeUpdate changeUpdate = ctx.getUpdate(psId);
+      commentsUtil.putComments(changeUpdate, Comment.Status.PUBLISHED, toPublish);
       comments.addAll(toPublish);
       return !toPublish.isEmpty();
     }
 
+    private void validateComments(Stream<Comment> comments) throws CommentsRejectedException {
+      ImmutableList<CommentForValidation> draftsForValidation =
+          comments
+              .map(
+                  comment ->
+                      CommentForValidation.create(
+                          comment.lineNbr > 0
+                              ? CommentForValidation.CommentType.INLINE_COMMENT
+                              : CommentForValidation.CommentType.FILE_COMMENT,
+                          comment.message))
+              .collect(toImmutableList());
+      ImmutableList<CommentValidationFailure> draftValidationFailures =
+          PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
+      if (!draftValidationFailures.isEmpty()) {
+        throw new CommentsRejectedException(draftValidationFailures);
+      }
+    }
+
     private boolean insertRobotComments(ChangeContext ctx) throws PatchListNotAvailableException {
       if (in.robotComments == null) {
         return false;
@@ -1003,7 +1051,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
@@ -1049,27 +1097,25 @@
     }
 
     private Map<String, Comment> changeDrafts(ChangeContext ctx) {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId())) {
-        c.tag = in.tag;
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
+      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+          .collect(
+              Collectors.toMap(
+                  c -> c.key.uuid,
+                  c -> {
+                    c.tag = in.tag;
+                    return c;
+                  }));
     }
 
     private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c :
-          commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes())) {
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
+      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
 
     private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
       Map<String, Short> labels = new HashMap<>();
       for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.getLabel(), psa.getValue());
+        labels.put(psa.label(), psa.value());
       }
       return labels;
     }
@@ -1106,15 +1152,9 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
-        return true;
-      }
       ChangeData cd = changeDataFactory.create(ctx.getNotes());
       ReviewerSet reviewers = cd.reviewers();
-      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
-        return true;
-      }
-      return false;
+      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
@@ -1149,35 +1189,40 @@
           // User requested delete of this label.
           oldApprovals.put(normName, null);
           if (c != null) {
-            if (c.getValue() != 0) {
+            if (c.value() != 0) {
               addLabelDelta(normName, (short) 0);
               oldApprovals.put(normName, previous.get(normName));
             }
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.getValue() != ent.getValue()) {
-          c.setValue(ent.getValue());
-          c.setGranted(ctx.getWhen());
-          c.setTag(in.tag);
-          ctx.getUser().updateRealAccountId(c::setRealAccountId);
+        } else if (c != null && c.value() != ent.getValue()) {
+          PatchSetApproval.Builder b =
+              c.toBuilder()
+                  .value(ent.getValue())
+                  .granted(ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag));
+          ctx.getUser().updateRealAccountId(b::realAccountId);
+          c = b.build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.getValue() == ent.getValue()) {
+        } else if (c != null && c.value() == ent.getValue()) {
           current.put(normName, c);
           oldApprovals.put(normName, null);
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
         } else if (c == null) {
-          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
+          c =
+              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putReviewer(user.getAccountId(), REVIEWER);
           update.putApproval(normName, ent.getValue());
         }
@@ -1219,7 +1264,7 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1231,7 +1276,7 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1240,8 +1285,8 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.getValue()); // Should be filtered out above.
-        if (prev > psa.getValue()) {
+        checkState(prev != psa.value()); // Should be filtered out above.
+        if (prev > psa.value()) {
           reduced.add(psa);
         }
         // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
@@ -1256,7 +1301,7 @@
         throw new ResourceConflictException(
             "Cannot reduce vote on labels for closed change: "
                 + reduced.stream()
-                    .map(PatchSetApproval::getLabel)
+                    .map(PatchSetApproval::label)
                     .distinct()
                     .sorted()
                     .collect(joining(", ")));
@@ -1283,18 +1328,16 @@
           }
 
           LabelId labelId = labelTypes.get(0).getLabelId();
-          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
-          ups.add(c);
+          ups.add(
+              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build());
         } else {
           // Pick a random label that is about to be deleted and keep it.
           Iterator<PatchSetApproval> i = del.iterator();
-          PatchSetApproval c = i.next();
-          c.setValue((short) 0);
-          c.setGranted(ctx.getWhen());
+          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
           i.remove();
-          ups.add(c);
         }
       }
       ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
@@ -1317,7 +1360,7 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.getLabelId());
+        LabelType lt = labelTypes.byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         } else {
@@ -1327,7 +1370,7 @@
       return current;
     }
 
-    private boolean insertMessage(ChangeContext ctx) {
+    private boolean insertMessage(ChangeContext ctx) throws CommentsRejectedException {
       String msg = Strings.nullToEmpty(in.message).trim();
 
       StringBuilder buf = new StringBuilder();
@@ -1340,6 +1383,15 @@
         buf.append(String.format("\n\n(%d comments)", comments.size()));
       }
       if (!msg.isEmpty()) {
+        ImmutableList<CommentValidationFailure> messageValidationFailure =
+            PublishCommentUtil.findInvalidComments(
+                commentValidators,
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.CHANGE_MESSAGE, msg)));
+        if (!messageValidationFailure.isEmpty()) {
+          throw new CommentsRejectedException(messageValidationFailure);
+        }
         buf.append("\n\n").append(msg);
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 8abd964..f74643c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerAdder;
@@ -59,7 +60,7 @@
   }
 
   @Override
-  protected AddReviewerResult applyImpl(
+  protected Response<AddReviewerResult> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
@@ -69,7 +70,7 @@
 
     ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
     if (addition.op == null) {
-      return addition.result;
+      return Response.ok(addition.result);
     }
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
@@ -81,7 +82,7 @@
 
     // Re-read change to take into account results of the update.
     addition.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
-    return addition.result;
+    return Response.ok(addition.result);
   }
 
   private NotifyResolver.Result resolveNotify(ChangeResource rsrc, AddReviewerInput input)
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index c3cee0e..e6a60d5 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -15,17 +15,18 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ArchiveFormat;
@@ -80,7 +81,7 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
+  public Response<BinaryResult> apply(RevisionResource rsrc)
       throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (Strings.isNullOrEmpty(format)) {
@@ -105,7 +106,7 @@
       throw new MethodNotAllowedException("Anonymous users cannot submit");
     }
 
-    return getBundles(rsrc, f);
+    return Response.ok(getBundles(rsrc, f));
   }
 
   private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
index a47037c..44f35a0 100644
--- a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -39,7 +39,7 @@
 
 @Singleton
 public class PublishChangeEdit
-    extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
+    extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Object> {
   private final ChangeEditUtil editUtil;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreementsChecker;
@@ -57,7 +57,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           NoSuchProjectException {
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index a9a6f12..d4a14d4 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -22,9 +22,12 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
@@ -53,6 +56,7 @@
   private final ReviewerAdder reviewerAdder;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
+  private final ApprovalsUtil approvalsUtil;
 
   @Inject
   PutAssignee(
@@ -61,17 +65,19 @@
       RetryHelper retryHelper,
       ReviewerAdder reviewerAdder,
       AccountLoader.Factory accountLoaderFactory,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ApprovalsUtil approvalsUtil) {
     super(retryHelper);
     this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
     this.reviewerAdder = reviewerAdder;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
+    this.approvalsUtil = approvalsUtil;
   }
 
   @Override
-  protected AccountInfo applyImpl(
+  protected Response<AccountInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException {
@@ -97,12 +103,15 @@
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
-      ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
-      reviewersAddition.op.suppressEmail();
-      bu.addOp(rsrc.getId(), reviewersAddition.op);
+      ReviewerSet currentReviewers = approvalsUtil.getReviewers(rsrc.getNotes());
+      if (!currentReviewers.all().contains(assignee.getAccountId())) {
+        ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+        reviewersAddition.op.suppressEmail();
+        bu.addOp(rsrc.getId(), reviewersAddition.op);
+      }
 
       bu.execute();
-      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
+      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.getAccountId()));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 0ec38e1..451d010 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -39,7 +39,7 @@
 
 @Singleton
 public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, DescriptionInput, Response<String>>
+    extends RetryingRestModifyView<RevisionResource, DescriptionInput, String>
     implements UiAction<RevisionResource> {
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
@@ -57,7 +57,7 @@
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
-    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().getId());
+    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
         updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getChange().getId(), op);
@@ -84,7 +84,7 @@
     public boolean updateChange(ChangeContext ctx) {
       ChangeUpdate update = ctx.getUpdate(psId);
       newDescription = Strings.nullToEmpty(input.description);
-      oldDescription = Strings.nullToEmpty(psUtil.get(ctx.getNotes(), psId).getDescription());
+      oldDescription = psUtil.get(ctx.getNotes(), psId).description().orElse("");
       if (oldDescription.equals(newDescription)) {
         return false;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index f6c9abe..5696fcb 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -23,9 +25,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
@@ -49,7 +48,7 @@
 
 @Singleton
 public class PutDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
+    extends RetryingRestModifyView<DraftCommentResource, DraftInput, CommentInfo> {
 
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
@@ -124,7 +123,7 @@
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), origComment.key.patchSetId);
       ChangeUpdate update = ctx.getUpdate(psId);
 
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
@@ -138,9 +137,9 @@
         commentsUtil.deleteComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
       commentsUtil.putComments(
-          update, Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
+          update, Comment.Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c542164..acda547 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,8 +24,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -61,8 +61,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class PutMessage
-    extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
+public class PutMessage extends RetryingRestModifyView<ChangeResource, CommitMessageInput, String> {
 
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
@@ -111,15 +110,18 @@
     String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
 
     ensureCanEditCommitMessage(resource.getNotes());
-    ensureChangeIdIsCorrect(
-        projectCache.checkedGet(resource.getProject()).is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
-        resource.getChange().getKey().get(),
-        sanitizedCommitMessage);
+    sanitizedCommitMessage =
+        ensureChangeIdIsCorrect(
+            projectCache
+                .checkedGet(resource.getProject())
+                .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
+            resource.getChange().getKey().get(),
+            sanitizedCommitMessage);
 
     try (Repository repository = repositoryManager.openRepository(resource.getProject());
         RevWalk revWalk = new RevWalk(repository);
         ObjectInserter objectInserter = repository.newObjectInserter()) {
-      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit patchSetCommit = revWalk.parseCommit(ps.commitId());
 
       String currentCommitMessage = patchSetCommit.getFullMessage();
       if (input.message.equals(currentCommitMessage)) {
@@ -132,7 +134,7 @@
         // Ensure that BatchUpdate will update the same repo
         bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
 
-        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
+        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
         ObjectId newCommit =
             createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
         PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
@@ -193,7 +195,7 @@
     }
   }
 
-  private static void ensureChangeIdIsCorrect(
+  private static String ensureChangeIdIsCorrect(
       boolean requireChangeId, String currentChangeId, String newCommitMessage)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
@@ -204,14 +206,21 @@
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
     List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
-    if (requireChangeId && changeIdFooters.isEmpty()) {
-      throw new ResourceConflictException("missing Change-Id footer");
-    }
     if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
       throw new ResourceConflictException("wrong Change-Id footer");
     }
-    if (changeIdFooters.size() > 1) {
+
+    if (requireChangeId && revCommit.getFooterLines().isEmpty()) {
+      // sanitization always adds '\n' at the end.
+      newCommitMessage += "\n";
+    }
+
+    if (requireChangeId && changeIdFooters.isEmpty()) {
+      newCommitMessage += FooterConstants.CHANGE_ID.getName() + ": " + currentChangeId + "\n";
+    } else if (changeIdFooters.size() > 1) {
       throw new ResourceConflictException("multiple Change-Id footers");
     }
+
+    return newCommitMessage;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index abfa49b..cfeb884 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -41,7 +41,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, Response<String>>
+public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, String>
     implements UiAction<ChangeResource> {
   private final ChangeMessagesUtil cmUtil;
   private final TopicEdited topicEdited;
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index e1ed8d6..6e5f554 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
@@ -113,7 +114,7 @@
   }
 
   @Override
-  public List<?> apply(TopLevelResource rsrc)
+  public Response<List<?>> apply(TopLevelResource rsrc)
       throws BadRequestException, AuthException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
@@ -124,7 +125,7 @@
       logger.atFine().withCause(e).log("Reject change query with 400 Bad Request: %s", queries);
       throw new BadRequestException(e.getMessage(), e);
     }
-    return out.size() == 1 ? out.get(0) : out;
+    return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
   private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 782b91aa..af8f971 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -17,19 +17,20 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
@@ -98,7 +99,7 @@
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
     // Not allowed to rebase if the current patch set is locked.
@@ -131,14 +132,14 @@
               .setFireRevisionCreated(true));
       bu.execute();
     }
-    return json.create(OPTIONS).format(change.getProject(), change.getId());
+    return Response.ok(json.create(OPTIONS).format(change.getProject(), change.getId()));
   }
 
   private ObjectId findBaseRev(
       Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
       throws RestApiException, IOException, NoSuchChangeException, AuthException,
           PermissionBackendException {
-    Branch.NameKey destRefKey = rsrc.getChange().getDest();
+    BranchNameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
       return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
@@ -147,10 +148,10 @@
     String str = input.base.trim();
     if (str.equals("")) {
       // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.get());
+      Ref destRef = repo.exactRef(destRefKey.branch());
       if (destRef == null) {
         throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
       }
       return destRef.getObjectId();
     }
@@ -167,8 +168,8 @@
           String.format("Base change not found: %s", input.base), e);
     }
 
-    PatchSet.Id baseId = base.patchSet().getId();
-    if (change.getId().equals(baseId.getParentKey())) {
+    PatchSet.Id baseId = base.patchSet().id();
+    if (change.getId().equals(baseId.changeId())) {
       throw new ResourceConflictException("cannot rebase change onto itself");
     }
 
@@ -189,18 +190,18 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return ObjectId.fromString(base.patchSet().getRevision().get());
+    return base.patchSet().commitId();
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = ObjectId.fromString(base.getRevision().get());
-    ObjectId tipId = ObjectId.fromString(tip.getRevision().get());
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
     return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
     // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+    RevCommit c = rw.parseCommit(ps.commitId());
     return c.getParentCount() == 1;
   }
 
@@ -238,7 +239,7 @@
     }
 
     boolean enabled = false;
-    try (Repository repo = repoManager.openRepository(change.getDest().getParentKey());
+    try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
@@ -272,14 +273,15 @@
     }
 
     @Override
-    protected ChangeInfo applyImpl(
+    protected Response<ChangeInfo> applyImpl(
         BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws UpdateException, RestApiException, IOException, PermissionBackendException {
+        throws Exception {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
-      return rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input);
+      return Response.ok(
+          rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input).value());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 81294ed..7be8765 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -33,7 +33,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
+public class RebaseChangeEdit extends RetryingRestModifyView<ChangeResource, Input, Object> {
   private final GitRepositoryManager repositoryManager;
   private final ChangeEditModifier editModifier;
 
@@ -48,7 +48,8 @@
   }
 
   @Override
-  protected Response<?> applyImpl(BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
+  protected Response<Object> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     Project.NameKey project = rsrc.getProject();
     try (Repository repository = repositoryManager.openRepository(project)) {
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index 1421ab6..8040847 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -24,10 +24,10 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -74,8 +74,8 @@
       throws IOException, PermissionBackendException {
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
-    Map<String, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.getRevision().get());
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = byId.get(startPs.commitId());
     checkArgument(start != null, "%s not found in %s", startPs, in);
 
     // Map of patch set -> immediate parent.
@@ -89,12 +89,12 @@
 
     for (ChangeData cd : in) {
       for (PatchSet ps : cd.patchSets()) {
-        PatchSetData thisPsd = requireNonNull(byId.get(ps.getRevision().get()));
-        if (cd.getId().equals(start.id()) && !ps.getId().equals(start.psId())) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+        if (cd.getId().equals(start.id()) && !ps.id().equals(start.psId())) {
           otherPatchSetsOfStart.add(thisPsd);
         }
         for (RevCommit p : thisPsd.commit().getParents()) {
-          PatchSetData parentPsd = byId.get(p.name());
+          PatchSetData parentPsd = byId.get(p);
           if (parentPsd != null) {
             parents.put(thisPsd, parentPsd);
             children.put(parentPsd, thisPsd);
@@ -112,9 +112,9 @@
     return result;
   }
 
-  private Map<String, PatchSetData> collectById(List<ChangeData> in) throws IOException {
+  private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
-    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
+    Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(true);
@@ -126,10 +126,9 @@
             project,
             cd.change().getProject());
         for (PatchSet ps : cd.patchSets()) {
-          String id = ps.getRevision().get();
-          RevCommit c = rw.parseCommit(ObjectId.fromString(id));
+          RevCommit c = rw.parseCommit(ps.commitId());
           PatchSetData psd = PatchSetData.create(cd, ps, c);
-          result.put(id, psd);
+          result.put(ps.commitId(), psd);
         }
       }
     }
@@ -252,16 +251,16 @@
     abstract RevCommit commit();
 
     PatchSet.Id psId() {
-      return patchSet().getId();
+      return patchSet().id();
     }
 
     Change.Id id() {
-      return psId().getParentKey();
+      return psId().changeId();
     }
 
     @Override
     public final int hashCode() {
-      return Objects.hash(patchSet().getId(), commit());
+      return Objects.hash(patchSet().id(), commit());
     }
 
     @Override
@@ -270,7 +269,7 @@
         return false;
       }
       PatchSetData o = (PatchSetData) obj;
-      return Objects.equals(patchSet().getId(), o.patchSet().getId())
+      return Objects.equals(patchSet().id(), o.patchSet().id())
           && Objects.equals(commit(), o.commit());
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 5f56cdb..679d4f8 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -16,16 +16,17 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -81,7 +82,7 @@
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, RestoreInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException {
     // Not allowed to restore if the current patch set is locked.
@@ -95,7 +96,7 @@
         updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getId(), op).execute();
     }
-    return json.noOptions().format(op.change);
+    return Response.ok(json.noOptions().format(op.change));
   }
 
   private class Op implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index dd51e7f..e196abc 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -18,33 +18,32 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 
-import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -66,24 +65,19 @@
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.text.MessageFormat;
 import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
 public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
@@ -98,12 +92,12 @@
   private final PatchSetUtil psUtil;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeJson.Factory json;
-  private final Provider<PersonIdent> serverIdent;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeReverted changeReverted;
   private final ContributorAgreementsChecker contributorAgreements;
   private final ProjectCache projectCache;
   private final NotifyResolver notifyResolver;
+  private final CommitUtil commitUtil;
 
   @Inject
   Revert(
@@ -116,12 +110,12 @@
       PatchSetUtil psUtil,
       RevertedSender.Factory revertedSenderFactory,
       ChangeJson.Factory json,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ApprovalsUtil approvalsUtil,
       ChangeReverted changeReverted,
       ContributorAgreementsChecker contributorAgreements,
       ProjectCache projectCache,
-      NotifyResolver notifyResolver) {
+      NotifyResolver notifyResolver,
+      CommitUtil commitUtil) {
     super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
@@ -131,16 +125,16 @@
     this.psUtil = psUtil;
     this.revertedSenderFactory = revertedSenderFactory;
     this.json = json;
-    this.serverIdent = serverIdent;
     this.approvalsUtil = approvalsUtil;
     this.changeReverted = changeReverted;
     this.contributorAgreements = contributorAgreements;
     this.projectCache = projectCache;
     this.notifyResolver = notifyResolver;
+    this.commitUtil = commitUtil;
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
       throws IOException, RestApiException, UpdateException, NoSuchChangeException,
           PermissionBackendException, NoSuchProjectException, ConfigInvalidException {
@@ -154,13 +148,12 @@
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     Change.Id revertId = revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), input);
-    return json.noOptions().format(rsrc.getProject(), revertId);
+    return Response.ok(json.noOptions().format(rsrc.getProject(), revertId));
   }
 
   private Change.Id revert(
       BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, RevertInput input)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
-    String message = Strings.emptyToNull(input.message);
     Change.Id changeIdToRevert = notes.getChangeId();
     PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
     PatchSet patch = psUtil.get(notes, patchSetId);
@@ -173,50 +166,25 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
-      RevCommit commitToRevert =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
-      if (commitToRevert.getParentCount() == 0) {
-        throw new ResourceConflictException("Cannot revert initial commit");
-      }
 
       Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = serverIdent.get();
-      PersonIdent authorIdent =
-          user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
-
-      RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
-      revWalk.parseHeaders(parentToCommitToRevert);
-
-      CommitBuilder revertCommitBuilder = new CommitBuilder();
-      revertCommitBuilder.addParentId(commitToRevert);
-      revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-      revertCommitBuilder.setAuthor(authorIdent);
-      revertCommitBuilder.setCommitter(authorIdent);
-
-      Change changeToRevert = notes.getChange();
-      if (message == null) {
-        message =
-            MessageFormat.format(
-                ChangeMessages.get().revertChangeDefaultMessage,
-                changeToRevert.getSubject(),
-                patch.getRevision().get());
-      }
-
       ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
-      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+      Change changeToRevert = notes.getChange();
+      ObjectId revertCommitId =
+          commitUtil.createRevertCommit(
+              input.message, notes, user, generatedChangeId, now, oi, revWalk);
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ObjectId id = oi.insert(revertCommitBuilder);
-      RevCommit revertCommit = revWalk.parseCommit(id);
+      RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
 
+      Change.Id changeId = Change.id(seq.nextChangeId());
       NotifyResolver.Result notify =
           notifyResolver.resolve(
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
       ChangeInserter ins =
           changeInserterFactory
-              .create(changeId, revertCommit, notes.getChange().getDest().get())
-              .setTopic(changeToRevert.getTopic());
+              .create(changeId, revertCommit, notes.getChange().getDest().branch())
+              .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
       ins.setMessage("Uploaded patch set 1.");
 
       ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
index 4594503..7152799 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -40,9 +40,9 @@
           accountPatchReviewStore.call(
               s ->
                   s.markReviewed(
-                      resource.getPatchKey().getParentKey(),
+                      resource.getPatchKey().patchSetId(),
                       resource.getAccountId(),
-                      resource.getPatchKey().getFileName()));
+                      resource.getPatchKey().fileName()));
       return reviewFlagUpdated ? Response.created("") : Response.ok("");
     }
   }
@@ -61,9 +61,9 @@
       accountPatchReviewStore.run(
           s ->
               s.clearReviewed(
-                  resource.getPatchKey().getParentKey(),
+                  resource.getPatchKey().patchSetId(),
                   resource.getAccountId(),
-                  resource.getPatchKey().getFileName()));
+                  resource.getPatchKey().fileName()));
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 92f7185..f07d815 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
@@ -22,18 +21,20 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -44,11 +45,11 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -63,13 +64,6 @@
 public class ReviewerRecommender {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final double BASE_REVIEWER_WEIGHT = 10;
-  private static final double BASE_OWNER_WEIGHT = 1;
-  private static final double BASE_COMMENT_WEIGHT = 0.5;
-  private static final double[] WEIGHTS =
-      new double[] {
-        BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,
-      };
   private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms
 
   private final ChangeQueryBuilder changeQueryBuilder;
@@ -78,6 +72,7 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ExecutorService executor;
   private final ApprovalsUtil approvalsUtil;
+  private final AccountCache accountCache;
 
   @Inject
   ReviewerRecommender(
@@ -86,16 +81,19 @@
       Provider<InternalChangeQuery> queryProvider,
       @FanOutExecutor ExecutorService executor,
       ApprovalsUtil approvalsUtil,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      AccountCache accountCache) {
     this.changeQueryBuilder = changeQueryBuilder;
     this.config = config;
     this.queryProvider = queryProvider;
     this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
     this.executor = executor;
     this.approvalsUtil = approvalsUtil;
+    this.accountCache = accountCache;
   }
 
   public List<Account.Id> suggestReviewers(
+      ReviewerState reviewerState,
       @Nullable ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
       ProjectState projectState,
@@ -109,12 +107,7 @@
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
     logger.atFine().log("base weight: %s", baseWeight);
 
-    Map<Account.Id, MutableDouble> reviewerScores;
-    if (Strings.isNullOrEmpty(query)) {
-      reviewerScores = baseRankingForEmptyQuery(baseWeight);
-    } else {
-      reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
-    }
+    Map<Account.Id, MutableDouble> reviewerScores = baseRanking(baseWeight, query, candidateList);
     logger.atFine().log("Base reviewer scores: %s", reviewerScores);
 
     // Send the query along with a candidate list to all plugins and merge the
@@ -178,7 +171,7 @@
       // Remove existing reviewers
       approvalsUtil
           .getReviewers(changeNotes)
-          .byState(REVIEWER)
+          .byState(ReviewerStateInternal.fromReviewerState(reviewerState))
           .forEach(
               r -> {
                 if (reviewerScores.remove(r) != null) {
@@ -196,7 +189,18 @@
     return sortedSuggestions;
   }
 
-  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
+  /**
+   * @param baseWeight The weight applied to the ordering of the reviewers.
+   * @param query Query to match. For example, it can try to match all users that start with "Ab".
+   * @param candidateList The list of candidates based on the query. If query is empty, this list is
+   *     also empty.
+   * @return Map of account ids that match the query and their appropriate ranking (the better the
+   *     ranking, the better it is to suggest them as reviewers).
+   * @throws IOException Can't find owner="self" account.
+   * @throws ConfigInvalidException Can't find owner="self" account.
+   */
+  private Map<Account.Id, MutableDouble> baseRanking(
+      double baseWeight, String query, List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
     // Get the user's last 25 changes, check approvals
     try {
@@ -206,14 +210,15 @@
               .setLimit(25)
               .setRequestedFields(ChangeField.APPROVAL)
               .query(changeQueryBuilder.owner("self"));
-      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
+      Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
+      // Put those candidates at the bottom of the list
+      candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0)));
+
       for (ChangeData cd : result) {
         for (PatchSetApproval approval : cd.currentApprovals()) {
-          Account.Id id = approval.getAccountId();
-          if (suggestions.containsKey(id)) {
-            suggestions.get(id).add(baseWeight);
-          } else {
-            suggestions.put(id, new MutableDouble(baseWeight));
+          Account.Id id = approval.accountId();
+          if (Strings.isNullOrEmpty(query) || accountMatchesQuery(id, query)) {
+            suggestions.computeIfAbsent(id, (ignored) -> new MutableDouble(0)).add(baseWeight);
           }
         }
       }
@@ -225,63 +230,15 @@
     }
   }
 
-  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
-      List<Account.Id> candidates, ProjectState projectState, double baseWeight)
-      throws IOException, ConfigInvalidException {
-    // Get each reviewer's activity based on number of applied labels
-    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
-    // changes (weighted 1d).
-    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
-    if (candidates.isEmpty()) {
-      return reviewers;
-    }
-    List<Predicate<ChangeData>> predicates = new ArrayList<>();
-    for (Account.Id id : candidates) {
-      try {
-        Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
-
-        // Get all labels for this project and create a compound OR query to
-        // fetch all changes where users have applied one of these labels
-        List<LabelType> labelTypes = projectState.getLabelTypes().getLabelTypes();
-        List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
-        for (LabelType type : labelTypes) {
-          labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
-        }
-        Predicate<ChangeData> reviewerQuery =
-            Predicate.and(projectQuery, Predicate.or(labelPredicates));
-
-        Predicate<ChangeData> ownerQuery =
-            Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString()));
-        Predicate<ChangeData> commentedByQuery =
-            Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString()));
-
-        predicates.add(reviewerQuery);
-        predicates.add(ownerQuery);
-        predicates.add(commentedByQuery);
-        reviewers.put(id, new MutableDouble());
-      } catch (QueryParseException e) {
-        // Unhandled: If an exception is thrown, we won't increase the
-        // candidates's score
-        logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
+  private boolean accountMatchesQuery(Account.Id id, String query) {
+    Optional<Account> account = accountCache.get(id).map(AccountState::account);
+    if (account.isPresent() && account.get().isActive()) {
+      if ((account.get().fullName() != null && account.get().fullName().startsWith(query))
+          || (account.get().preferredEmail() != null
+              && account.get().preferredEmail().startsWith(query))) {
+        return true;
       }
     }
-
-    List<List<ChangeData>> result = queryProvider.get().setLimit(25).noFields().query(predicates);
-
-    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
-    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
-
-    int i = 0;
-    Account.Id currentId = null;
-    while (queryResultIterator.hasNext()) {
-      List<ChangeData> currentResult = queryResultIterator.next();
-      if (i % WEIGHTS.length == 0) {
-        currentId = reviewersIterator.next();
-      }
-
-      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size());
-      i++;
-    }
-    return reviewers;
+    return false;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index 546ca01..b2714da 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index f5907e7..676cc07 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -24,21 +24,26 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.index.query.TooManyTermsInQueryException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
@@ -49,6 +54,7 @@
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -113,12 +119,9 @@
     }
   }
 
-  // Generate a candidate list at 3x the size of what the user wants to see to
-  // give the ranking algorithm a good set of candidates it can work with
-  private static final int CANDIDATE_LIST_MULTIPLIER = 3;
-
   private final AccountLoader.Factory accountLoaderFactory;
   private final AccountQueryBuilder accountQueryBuilder;
+  private final AccountIndexRewriter accountIndexRewriter;
   private final GroupBackend groupBackend;
   private final GroupMembers groupMembers;
   private final ReviewerRecommender reviewerRecommender;
@@ -132,6 +135,7 @@
   ReviewersUtil(
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
+      AccountIndexRewriter accountIndexRewriter,
       GroupBackend groupBackend,
       GroupMembers groupMembers,
       ReviewerRecommender reviewerRecommender,
@@ -142,6 +146,7 @@
       Provider<CurrentUser> self) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
+    this.accountIndexRewriter = accountIndexRewriter;
     this.groupBackend = groupBackend;
     this.groupMembers = groupMembers;
     this.reviewerRecommender = reviewerRecommender;
@@ -157,12 +162,13 @@
   }
 
   public List<SuggestedReviewerInfo> suggestReviewers(
+      ReviewerState reviewerState,
       @Nullable ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
       ProjectState projectState,
       VisibilityControl visibilityControl,
       boolean excludeGroups)
-      throws IOException, ConfigInvalidException, PermissionBackendException {
+      throws IOException, ConfigInvalidException, PermissionBackendException, BadRequestException {
     CurrentUser currentUser = self.get();
     if (changeNotes != null) {
       logger.atFine().log(
@@ -190,7 +196,8 @@
     }
 
     List<Account.Id> sortedRecommendations =
-        recommendAccounts(changeNotes, suggestReviewers, projectState, candidateList);
+        recommendAccounts(
+            reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);
 
     // Filter accounts by visibility and enforce limit
@@ -221,37 +228,59 @@
     return suggestedReviewers;
   }
 
-  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) {
+  private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
+    if (useLegacyNumericFields) {
+      return Account.id(f.getValue(AccountField.ID).intValue());
+    }
+    return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
+  }
+
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
+      throws BadRequestException {
     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
-      try {
-        // For performance reasons we don't use AccountQueryProvider as it would always load the
-        // complete account from the cache (or worse, from NoteDb) even though we only need the ID
-        // which we can directly get from the returned results.
-        Predicate<AccountState> pred =
-            Predicate.and(
-                AccountPredicates.isActive(),
-                accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
-        logger.atFine().log("accounts index query: %s", pred);
-        ResultSet<FieldBundle> result =
-            accountIndexes
-                .getSearchIndex()
-                .getSource(
-                    pred,
-                    QueryOptions.create(
-                        indexConfig,
-                        0,
-                        suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
-                        ImmutableSet.of(AccountField.ID.getName())))
-                .readRaw();
-        List<Account.Id> matches =
-            result.toList().stream()
-                .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
-                .collect(toList());
-        logger.atFine().log("Matches: %s", matches);
-        return matches;
-      } catch (QueryParseException e) {
+      // For performance reasons we don't use AccountQueryProvider as it would always load the
+      // complete account from the cache (or worse, from NoteDb) even though we only need the ID
+      // which we can directly get from the returned results.
+      Predicate<AccountState> pred =
+          Predicate.and(
+              AccountPredicates.isActive(),
+              accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+      logger.atFine().log("accounts index query: %s", pred);
+      accountIndexRewriter.validateMaxTermsInQuery(pred);
+      boolean useLegacyNumericFields =
+          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
+      FieldDef<AccountState, ?> idField =
+          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
+      ResultSet<FieldBundle> result =
+          accountIndexes
+              .getSearchIndex()
+              .getSource(
+                  pred,
+                  QueryOptions.create(
+                      indexConfig,
+                      0,
+                      suggestReviewers.getLimit(),
+                      ImmutableSet.of(idField.getName())))
+              .readRaw();
+      List<Account.Id> matches =
+          result.toList().stream()
+              .map(f -> fromIdField(f, useLegacyNumericFields))
+              .collect(toList());
+      logger.atFine().log("Matches: %s", matches);
+      return matches;
+    } catch (TooManyTermsInQueryException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log("Suggesting accounts failed, return empty result.");
+      return ImmutableList.of();
+    } catch (StorageException e) {
+      if (e.getCause() instanceof TooManyTermsInQueryException) {
+        throw new BadRequestException(e.getMessage());
+      }
+      if (e.getCause() instanceof QueryParseException) {
         return ImmutableList.of();
       }
+      throw e;
     }
   }
 
@@ -286,6 +315,7 @@
   }
 
   private List<Account.Id> recommendAccounts(
+      ReviewerState reviewerState,
       @Nullable ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
       ProjectState projectState,
@@ -293,7 +323,7 @@
       throws IOException, ConfigInvalidException {
     try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
       return reviewerRecommender.suggestReviewers(
-          changeNotes, suggestReviewers, projectState, candidateList);
+          reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     }
   }
 
@@ -405,7 +435,7 @@
 
       // require that at least one member in the group can see the change
       for (Account account : members) {
-        if (visibilityControl.isVisibleTo(account.getId())) {
+        if (visibilityControl.isVisibleTo(account.id())) {
           if (needsConfirmation) {
             result.allowedWithConfirmation = true;
           } else {
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index a41143c..ac0945d 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -23,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index c5cce4f..7ca989c 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -16,14 +16,15 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -36,11 +37,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -121,42 +124,46 @@
     } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
       // Legacy patch set number syntax.
       return byLegacyPatchSetId(change, id);
-    } else if (id.length() < 4 || id.length() > RevId.LEN) {
+    } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
       return Collections.emptyList();
     } else {
       List<RevisionResource> out = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(change.getNotes())) {
-        if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
+        if (ObjectIds.matchesAbbreviation(ps.commitId(), id)) {
           out.add(new RevisionResource(change, ps));
         }
       }
       // Not an existing patch set on a change, but might be an edit.
-      if (out.isEmpty() && id.length() == RevId.LEN) {
-        return loadEdit(change, new RevId(id));
+      if (out.isEmpty() && ObjectId.isId(id)) {
+        return loadEdit(change, ObjectId.fromString(id));
       }
       return out;
     }
   }
 
   private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
-    PatchSet ps =
-        psUtil.get(change.getNotes(), new PatchSet.Id(change.getId(), Integer.parseInt(id)));
+    PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
     }
     return Collections.emptyList();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
+  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
       throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
-      ps.setRevision(editRevId);
-      if (revid == null || editRevId.equals(revid)) {
+      RevCommit editCommit = edit.get().getEditCommit();
+      PatchSet ps =
+          PatchSet.builder()
+              .id(PatchSet.id(change.getId(), 0))
+              .commitId(editCommit)
+              .uploader(change.getUser().getAccountId())
+              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .build();
+      if (commitId == null || editCommit.equals(commitId)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RobotComments.java b/java/com/google/gerrit/server/restapi/change/RobotComments.java
index 1aa8c2a..9f81d0a 100644
--- a/java/com/google/gerrit/server/restapi/change/RobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/RobotComments.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.RobotCommentResource;
@@ -59,7 +59,7 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new RobotCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index aacf58b..288806c 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -39,7 +39,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, String>
     implements UiAction<ChangeResource> {
   private final WorkInProgressOp.Factory opFactory;
 
@@ -50,7 +50,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 852813e..3fb0295 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -39,7 +39,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, String>
     implements UiAction<ChangeResource> {
   private final WorkInProgressOp.Factory opFactory;
 
@@ -50,7 +50,7 @@
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 51e2dfb..c4cca51 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,29 +14,32 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -48,11 +51,9 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -107,7 +108,6 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountResolver accountResolver;
@@ -127,7 +127,6 @@
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
-      ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountResolver accountResolver,
@@ -138,7 +137,6 @@
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
-    this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accountResolver = accountResolver;
@@ -173,7 +171,7 @@
   }
 
   @Override
-  public Output apply(RevisionResource rsrc, SubmitInput input)
+  public Response<Output> apply(RevisionResource rsrc, SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
@@ -186,44 +184,47 @@
     }
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
-    return new Output(mergeChange(rsrc, submitter, input));
+    return mergeChange(rsrc, submitter, input);
   }
 
-  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
-      throws RestApiException, IOException, UpdateException, ConfigInvalidException,
-          PermissionBackendException {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Response<Output> mergeChange(
+      RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+      throws RestApiException, IOException {
     Change change = rsrc.getChange();
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
-          String.format("destination branch \"%s\" not found.", change.getDest().get()));
-    } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
+          String.format("destination branch \"%s\" not found.", change.getDest().branch()));
+    } else if (!rsrc.getPatchSet().id().equals(change.currentPatchSetId())) {
       // TODO Allow submitting non-current revision by changing the current.
       throw new ResourceConflictException(
           String.format(
-              "revision %s is not current revision", rsrc.getPatchSet().getRevision().get()));
+              "revision %s is not current revision", rsrc.getPatchSet().commitId().name()));
     }
 
     try (MergeOp op = mergeOpProvider.get()) {
-      op.merge(change, submitter, true, input, false);
-    }
+      Change updatedChange;
 
-    // Read the ChangeNotes only after MergeOp is fully done (including MergeOp#close) to be sure
-    // to have the correct state of the repo.
-    try {
-      change = changeNotesFactory.createChecked(change.getProject(), change.getId()).getChange();
-    } catch (NoSuchChangeException e) {
-      throw new ResourceConflictException("change is deleted", e);
-    }
+      try {
+        updatedChange = op.merge(change, submitter, true, input, false);
+      } catch (Exception e) {
+        Throwables.throwIfInstanceOf(e, RestApiException.class);
+        return Response.<Output>internalServerError(e).traceId(op.getTraceId().orElse(null));
+      }
 
-    if (change.isMerged()) {
-      return change;
+      if (updatedChange.isMerged()) {
+        return Response.ok(new Output(change));
+      }
+
+      String msg =
+          String.format(
+              "change %s of project %s unexpectedly had status %s after submit attempt",
+              updatedChange.getId(), updatedChange.getProject(), updatedChange.getStatus());
+      logger.atWarning().log(msg);
+      throw new RestApiException(msg);
     }
-    if (change.isNew()) {
-      throw new RestApiException("change unexpectedly had status NEW after submit attempt");
-    }
-    throw new ResourceConflictException("change is " + ChangeUtil.status(change));
   }
 
   /**
@@ -356,12 +357,11 @@
           .setVisible(true)
           .setEnabled(Boolean.TRUE.equals(enabled));
     }
-    RevId revId = resource.getPatchSet().getRevision();
     Map<String, String> params =
         ImmutableMap.of(
-            "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", change.getDest().getShortName(),
-            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+            "patchSet", String.valueOf(resource.getPatchSet().number()),
+            "branch", change.getDest().shortName(),
+            "commit", abbreviateName(resource.getPatchSet().commitId()),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
     return new UiAction.Description()
@@ -377,10 +377,10 @@
       mergeabilityMap.add(change);
     }
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
-    for (Branch.NameKey branch : cbb.keySet()) {
+    ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
+    for (BranchNameKey branch : cbb.keySet()) {
       Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.getParentKey());
+      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.project());
 
       Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
       for (RevCommit commit : commits.values()) {
@@ -425,9 +425,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk walk = new RevWalk(repo)) {
       for (ChangeData change : changes) {
-        RevCommit commit =
-            walk.parseCommit(
-                ObjectId.fromString(psUtil.current(change.notes()).getRevision().get()));
+        RevCommit commit = walk.parseCommit(psUtil.current(change.notes()).commitId());
         commits.put(change.getId(), commit);
       }
     }
@@ -466,16 +464,20 @@
     }
 
     @Override
-    public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException,
-            PermissionBackendException, UpdateException, ConfigInvalidException {
+    public Response<ChangeInfo> apply(ChangeResource rsrc, SubmitInput input) throws Exception {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
 
-      Output out = submit.apply(new RevisionResource(rsrc, ps), input);
-      return json.noOptions().format(out.change);
+      Response<Output> response = submit.apply(new RevisionResource(rsrc, ps), input);
+      if (response instanceof Response.InternalServerError) {
+        Response.InternalServerError<?> ise = (Response.InternalServerError<?>) response;
+        return Response.<ChangeInfo>internalServerError(ise.cause())
+            .traceId(ise.traceId().orElse(null));
+      }
+
+      return Response.ok(json.noOptions().format(response.value().change));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index cefbfdb..214a001 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -19,6 +19,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -26,8 +27,8 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WalkSorter;
@@ -58,7 +59,8 @@
       EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.SUBMITTABLE);
 
   private static final Comparator<ChangeData> COMPARATOR =
-      Comparator.comparing(ChangeData::project).thenComparing(cd -> cd.getId().id, reverseOrder());
+      Comparator.comparing(ChangeData::project)
+          .thenComparing(cd -> cd.getId().get(), reverseOrder());
 
   private final ChangeJson.Factory json;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -107,14 +109,14 @@
   }
 
   @Override
-  public Object apply(ChangeResource resource)
+  public Response<Object> apply(ChangeResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
           PermissionBackendException {
     SubmittedTogetherInfo info = applyInfo(resource);
     if (options.isEmpty()) {
-      return info.changes;
+      return Response.ok(info.changes);
     }
-    return info;
+    return Response.ok(info);
   }
 
   public SubmittedTogetherInfo applyInfo(ChangeResource resource)
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index f5a2751..d247b5b 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
@@ -38,15 +40,31 @@
 public class SuggestChangeReviewers extends SuggestReviewers
     implements RestReadView<ChangeResource> {
 
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
+
+  private boolean excludeGroups;
+  private ReviewerState reviewerState = ReviewerState.REVIEWER;
+
   @Option(
       name = "--exclude-groups",
       aliases = {"-e"},
       usage = "exclude groups from query")
-  boolean excludeGroups;
+  public SuggestChangeReviewers setExcludeGroups(boolean excludeGroups) {
+    this.excludeGroups = excludeGroups;
+    return this;
+  }
 
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-  private final ProjectCache projectCache;
+  @Option(
+      name = "--reviewer-state",
+      usage =
+          "The type of reviewers that should be suggested"
+              + " (can be 'REVIEWER' or 'CC', default is 'REVIEWER')")
+  public SuggestChangeReviewers setReviewerState(ReviewerState reviewerState) {
+    this.reviewerState = reviewerState;
+    return this;
+  }
 
   @Inject
   SuggestChangeReviewers(
@@ -63,18 +81,24 @@
   }
 
   @Override
-  public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
+  public Response<List<SuggestedReviewerInfo>> apply(ChangeResource rsrc)
       throws AuthException, BadRequestException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return reviewersUtil.suggestReviewers(
-        rsrc.getNotes(),
-        this,
-        projectCache.checkedGet(rsrc.getProject()),
-        getVisibility(rsrc),
-        excludeGroups);
+    if (reviewerState.equals(ReviewerState.REMOVED)) {
+      throw new BadRequestException(
+          String.format("Unsupported reviewer state: %s", ReviewerState.REMOVED));
+    }
+    return Response.ok(
+        reviewersUtil.suggestReviewers(
+            reviewerState,
+            rsrc.getNotes(),
+            this,
+            projectCache.checkedGet(rsrc.getProject()),
+            getVisibility(rsrc),
+            excludeGroups));
   }
 
   private VisibilityControl getVisibility(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 4904da7..bae2e52 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
@@ -24,20 +22,19 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.inject.Inject;
 import java.util.LinkedHashMap;
-import java.util.List;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
@@ -45,7 +42,6 @@
   private final RulesCache rules;
   private final AccountLoader.Factory accountInfoFactory;
   private final ProjectCache projectCache;
-  private final DefaultSubmitRule defaultSubmitRule;
   private final PrologRule prologRule;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
@@ -57,56 +53,41 @@
       RulesCache rules,
       AccountLoader.Factory infoFactory,
       ProjectCache projectCache,
-      DefaultSubmitRule defaultSubmitRule,
       PrologRule prologRule) {
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
     this.accountInfoFactory = infoFactory;
     this.projectCache = projectCache;
-    this.defaultSubmitRule = defaultSubmitRule;
     this.prologRule = prologRule;
   }
 
   @Override
-  public List<TestSubmitRuleInfo> apply(RevisionResource rsrc, TestSubmitRuleInput input)
+  public Response<TestSubmitRuleInfo> apply(RevisionResource rsrc, TestSubmitRuleInput input)
       throws AuthException, PermissionBackendException, BadRequestException {
     if (input == null) {
       input = new TestSubmitRuleInput();
     }
-    if (input.rule != null && !rules.isProjectRulesEnabled()) {
+    if (input.rule == null) {
+      throw new BadRequestException("rule is required");
+    }
+    if (!rules.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
-    SubmitRuleOptions opts =
-        SubmitRuleOptions.builder()
-            .skipFilters(input.filters == Filters.SKIP)
-            .rule(input.rule)
-            .logErrors(false)
-            .build();
-
     ProjectState projectState = projectCache.get(rsrc.getProject());
     if (projectState == null) {
       throw new BadRequestException("project not found");
     }
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    List<SubmitRecord> records;
-    if (projectState.hasPrologRules() || input.rule != null) {
-      records = ImmutableList.copyOf(prologRule.evaluate(cd, opts));
-    } else {
-      // No rules were provided as input and we have no rules.pl. This means we are supposed to run
-      // the default rules. Nowadays, the default rules are implemented in Java, not Prolog.
-      // Therefore, we call the DefaultRuleEvaluator instead.
-      records = ImmutableList.copyOf(defaultSubmitRule.evaluate(cd, opts));
-    }
+    SubmitRecord record =
+        prologRule.evaluate(
+            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
 
-    List<TestSubmitRuleInfo> out = Lists.newArrayListWithCapacity(records.size());
     AccountLoader accounts = accountInfoFactory.create(true);
-    for (SubmitRecord r : records) {
-      out.add(newSubmitRuleInfo(r, accounts));
-    }
+    TestSubmitRuleInfo out = newSubmitRuleInfo(record, accounts);
     accounts.fill();
-    return out;
+    return Response.ok(out);
   }
 
   private static TestSubmitRuleInfo newSubmitRuleInfo(SubmitRecord r, AccountLoader accounts) {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index 46dbad6..cb52fcb 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -21,12 +21,16 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologOptions;
+import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
@@ -34,61 +38,69 @@
 public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final PrologRule prologRule;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitType(
-      ChangeData.Factory changeDataFactory,
-      RulesCache rules,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+  TestSubmitType(ChangeData.Factory changeDataFactory, RulesCache rules, PrologRule prologRule) {
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    this.prologRule = prologRule;
   }
 
   @Override
-  public SubmitType apply(RevisionResource rsrc, TestSubmitRuleInput input)
+  public Response<SubmitType> apply(RevisionResource rsrc, TestSubmitRuleInput input)
       throws AuthException, BadRequestException {
     if (input == null) {
       input = new TestSubmitRuleInput();
     }
-    if (input.rule != null && !rules.isProjectRulesEnabled()) {
+    if (input.rule == null) {
+      throw new BadRequestException("rule is required");
+    }
+    if (!rules.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
-    SubmitRuleOptions opts =
-        SubmitRuleOptions.builder()
-            .logErrors(false)
-            .skipFilters(input.filters == Filters.SKIP)
-            .rule(input.rule)
-            .build();
-
-    SubmitRuleEvaluator evaluator = submitRuleEvaluatorFactory.create(opts);
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    SubmitTypeRecord rec = evaluator.getSubmitType(cd);
+    SubmitTypeRecord rec =
+        prologRule.getSubmitType(
+            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
 
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new BadRequestException(String.format("rule produced invalid result: %s", rec));
     }
 
-    return rec.type;
+    return Response.ok(rec.type);
   }
 
   public static class Get implements RestReadView<RevisionResource> {
-    private final TestSubmitType test;
+    private final ChangeData.Factory changeDataFactory;
+    private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
     @Inject
-    Get(TestSubmitType test) {
-      this.test = test;
+    Get(
+        ChangeData.Factory changeDataFactory,
+        SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      this.changeDataFactory = changeDataFactory;
+      this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
     }
 
     @Override
-    public SubmitType apply(RevisionResource resource) throws AuthException, BadRequestException {
-      return test.apply(resource, null);
+    public Response<SubmitType> apply(RevisionResource resource)
+        throws AuthException, ResourceConflictException {
+      SubmitRuleEvaluator evaluator =
+          submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+      ChangeData cd = changeDataFactory.create(resource.getNotes());
+      SubmitTypeRecord rec = evaluator.getSubmitType(cd);
+
+      if (rec.status != SubmitTypeRecord.Status.OK) {
+        throw new ResourceConflictException(String.format("rule produced invalid result: %s", rec));
+      }
+
+      return Response.ok(rec.type);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index 31efe54..d899002 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
@@ -71,7 +72,8 @@
     }
 
     @Override
-    public Map<String, Short> apply(ReviewerResource rsrc) throws MethodNotAllowedException {
+    public Response<Map<String, Short>> apply(ReviewerResource rsrc)
+        throws MethodNotAllowedException {
       if (rsrc.getRevisionResource() != null && !rsrc.getRevisionResource().isCurrent()) {
         throw new MethodNotAllowedException("Cannot list votes on non-current patch set");
       }
@@ -85,9 +87,9 @@
               null,
               null);
       for (PatchSetApproval psa : byPatchSetUser) {
-        votes.put(psa.getLabel(), psa.getValue());
+        votes.put(psa.label(), psa.value());
       }
-      return votes;
+      return Response.ok(votes);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
index 61d5c79..50e774a 100644
--- a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
+++ b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckGroupsResultInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountsConsistencyChecker;
@@ -54,7 +55,7 @@
   }
 
   @Override
-  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+  public Response<ConsistencyCheckInfo> apply(ConfigResource resource, ConsistencyCheckInput input)
       throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
     permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
 
@@ -80,6 +81,6 @@
           new CheckGroupsResultInfo(groupsConsistencyChecker.check());
     }
 
-    return consistencyCheckInfo;
+    return Response.ok(consistencyCheckInfo);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
index b6dcd35..b56f1b8 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
index 5abaf1e..93600ea 100644
--- a/java/com/google/gerrit/server/restapi/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetCache implements RestReadView<CacheResource> {
 
   @Override
-  public ListCaches.CacheInfo apply(CacheResource rsrc) {
-    return new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache());
+  public Response<ListCaches.CacheInfo> apply(CacheResource rsrc) {
+    return Response.ok(new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 13c2818..44c71b3 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -17,8 +17,9 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -41,10 +42,10 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource)
+  public Response<DiffPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultDiffPreferences(allUsersName, git);
+      return Response.ok(StoredPreferences.readDefaultDiffPreferences(allUsersName, git));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
index 2ec547b..a5ab967 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -17,8 +17,9 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -40,10 +41,10 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(ConfigResource configResource)
+  public Response<EditPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultEditPreferences(allUsersName, git);
+      return Response.ok(StoredPreferences.readDefaultEditPreferences(allUsersName, git));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
index 4dbbc8c..8da9134 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -38,10 +39,10 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc)
+  public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      return Preferences.readDefaultGeneralPreferences(allUsersName, git);
+      return Response.ok(StoredPreferences.readDefaultGeneralPreferences(allUsersName, git));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index aa0e350..2d504c7 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.EnableSignedPush;
@@ -133,7 +134,7 @@
   }
 
   @Override
-  public ServerInfo apply(ConfigResource rsrc) throws PermissionBackendException {
+  public Response<ServerInfo> apply(ConfigResource rsrc) throws PermissionBackendException {
     ServerInfo info = new ServerInfo();
     info.accounts = getAccountsInfo();
     info.auth = getAuthInfo();
@@ -148,7 +149,7 @@
 
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
-    return info;
+    return Response.ok(info);
   }
 
   private AccountsInfo getAccountsInfo() {
@@ -225,7 +226,9 @@
             + "\u2026";
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
-    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(config);
+    info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
+    info.excludeMergeableInChangeInfo =
+        toBoolean(this.config.getBoolean("change", "api", "excludeMergeableInChangeInfo", false));
     info.disablePrivateChanges =
         toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
     return info;
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 1d8da63..d0a1498 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.SitePath;
@@ -69,7 +70,7 @@
   }
 
   @Override
-  public SummaryInfo apply(ConfigResource rsrc) {
+  public Response<SummaryInfo> apply(ConfigResource rsrc) {
     if (gc) {
       System.gc();
       System.runFinalization();
@@ -83,7 +84,7 @@
     if (jvm) {
       summary.jvmSummary = getJvmSummary();
     }
-    return summary;
+    return Response.ok(summary);
   }
 
   private TaskSummaryInfo getTaskSummary() {
diff --git a/java/com/google/gerrit/server/restapi/config/GetTask.java b/java/com/google/gerrit/server/restapi/config/GetTask.java
index a32f3ba..513c99a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetTask.java
+++ b/java/com/google/gerrit/server/restapi/config/GetTask.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.TaskResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetTask implements RestReadView<TaskResource> {
 
   @Override
-  public ListTasks.TaskInfo apply(TaskResource rsrc) {
-    return new ListTasks.TaskInfo(rsrc.getTask());
+  public Response<ListTasks.TaskInfo> apply(TaskResource rsrc) {
+    return Response.ok(new ListTasks.TaskInfo(rsrc.getTask()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index 6ba93e8..904c44f 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -51,7 +51,7 @@
   }
 
   @Override
-  public Object apply(ConfigResource resource, Input input) {
+  public Response<String> apply(ConfigResource resource, Input input) {
     if (input == null || input.changes == null) {
       return Response.ok("Nothing to index");
     }
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index f310ed7..ccafbe8 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.config.ConfigResource;
@@ -69,21 +70,22 @@
   }
 
   @Override
-  public Object apply(ConfigResource rsrc) {
+  public Response<Object> apply(ConfigResource rsrc) {
     if (format == null) {
-      return getCacheInfos();
+      return Response.ok(getCacheInfos());
     }
     Stream<String> cacheNames =
         Streams.stream(cacheMap)
             .map(e -> cacheNameOf(e.getPluginName(), e.getExportName()))
             .sorted();
     if (OutputFormat.TEXT_LIST.equals(format)) {
-      return BinaryResult.create(cacheNames.collect(joining("\n")))
-          .base64()
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
+      return Response.ok(
+          BinaryResult.create(cacheNames.collect(joining("\n")))
+              .base64()
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
     }
-    return cacheNames.collect(toImmutableList());
+    return Response.ok(cacheNames.collect(toImmutableList()));
   }
 
   public enum CacheType {
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index cacbbf5..6c8bf74 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.CapabilityConstants;
 import com.google.gerrit.server.config.ConfigResource;
@@ -43,13 +44,14 @@
   }
 
   @Override
-  public Map<String, CapabilityInfo> apply(ConfigResource resource)
+  public Response<Map<String, CapabilityInfo>> apply(ConfigResource resource)
       throws ResourceNotFoundException, IllegalAccessException, NoSuchFieldException {
     permissionBackend.checkUsesDefaultCapabilities();
-    return ImmutableMap.<String, CapabilityInfo>builder()
-        .putAll(collectCoreCapabilities())
-        .putAll(collectPluginCapabilities())
-        .build();
+    return Response.ok(
+        ImmutableMap.<String, CapabilityInfo>builder()
+            .putAll(collectCoreCapabilities())
+            .putAll(collectPluginCapabilities())
+            .build());
   }
 
   public Map<String, CapabilityInfo> collectPluginCapabilities() {
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index 7402c15..6a3ca42 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -17,9 +17,10 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.WorkQueue;
@@ -62,7 +63,7 @@
   }
 
   @Override
-  public List<TaskInfo> apply(ConfigResource resource)
+  public Response<List<TaskInfo>> apply(ConfigResource resource)
       throws AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
@@ -72,7 +73,7 @@
     List<TaskInfo> allTasks = getTasks();
     try {
       permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
-      return allTasks;
+      return Response.ok(allTasks);
     } catch (AuthException e) {
       // Fall through to filter tasks.
     }
@@ -83,7 +84,7 @@
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          Project.NameKey nameKey = new Project.NameKey(task.projectName);
+          Project.NameKey nameKey = Project.nameKey(task.projectName);
           ProjectState state = projectCache.get(nameKey);
           if (state == null || !state.statePermitsRead()) {
             visible = false;
@@ -102,7 +103,7 @@
         }
       }
     }
-    return visibleTasks;
+    return Response.ok(visibleTasks);
   }
 
   private List<TaskInfo> getTasks() {
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
index 0685a58..9ce7ffd 100644
--- a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.api.config.ConfigUpdateEntryInfo;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.config.ConfigResource;
@@ -47,17 +48,18 @@
   }
 
   @Override
-  public Map<String, List<ConfigUpdateEntryInfo>> apply(ConfigResource resource, Input input)
-      throws RestApiException, PermissionBackendException {
+  public Response<Map<String, List<ConfigUpdateEntryInfo>>> apply(
+      ConfigResource resource, Input input) throws RestApiException, PermissionBackendException {
     permissions.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     Multimap<UpdateResult, ConfigUpdateEntry> updates = config.reloadConfig();
     if (updates.isEmpty()) {
-      return Collections.emptyMap();
+      return Response.ok(Collections.emptyMap());
     }
-    return updates.asMap().entrySet().stream()
-        .collect(
-            Collectors.toMap(
-                e -> e.getKey().name().toLowerCase(), e -> toEntryInfos(e.getValue())));
+    return Response.ok(
+        updates.asMap().entrySet().stream()
+            .collect(
+                Collectors.toMap(
+                    e -> e.getKey().name().toLowerCase(), e -> toEntryInfos(e.getValue()))));
   }
 
   private static List<ConfigUpdateEntryInfo> toEntryInfos(
diff --git a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
index 068f332..96654a9 100644
--- a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
@@ -21,9 +21,10 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -54,7 +55,8 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource, DiffPreferencesInfo input)
+  public Response<DiffPreferencesInfo> apply(
+      ConfigResource configResource, DiffPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
       throw new BadRequestException("input must be provided");
@@ -64,9 +66,9 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      DiffPreferencesInfo updatedPrefs = Preferences.updateDefaultDiffPreferences(md, input);
+      DiffPreferencesInfo updatedPrefs = StoredPreferences.updateDefaultDiffPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
index daca734..4bb420b 100644
--- a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
@@ -21,9 +21,10 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -54,7 +55,8 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(ConfigResource configResource, EditPreferencesInfo input)
+  public Response<EditPreferencesInfo> apply(
+      ConfigResource configResource, EditPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
       throw new BadRequestException("input must be provided");
@@ -64,9 +66,9 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      EditPreferencesInfo updatedPrefs = Preferences.updateDefaultEditPreferences(md, input);
+      EditPreferencesInfo updatedPrefs = StoredPreferences.updateDefaultEditPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/SetPreferences.java b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
index 6a0c22b..c88c1119 100644
--- a/java/com/google/gerrit/server/restapi/config/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
@@ -21,9 +21,10 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Preferences;
+import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -54,16 +55,17 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc, GeneralPreferencesInfo input)
+  public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc, GeneralPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (!hasSetFields(input)) {
       throw new BadRequestException("unsupported option");
     }
-    Preferences.validateMy(input.my);
+    StoredPreferences.validateMy(input.my);
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      GeneralPreferencesInfo updatedPrefs = Preferences.updateDefaultGeneralPreferences(md, input);
+      GeneralPreferencesInfo updatedPrefs =
+          StoredPreferences.updateDefaultGeneralPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 9a7aa3a..837d071 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -21,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.TaskResource;
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index b2b14a1..caff206 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -18,19 +18,19 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
@@ -111,7 +111,7 @@
   }
 
   @Override
-  public List<AccountInfo> apply(GroupResource resource, Input input)
+  public Response<List<AccountInfo>> apply(GroupResource resource, Input input)
       throws AuthException, NotInternalGroupException, UnprocessableEntityException, IOException,
           ConfigInvalidException, ResourceNotFoundException, PermissionBackendException {
     GroupDescription.Internal internalGroup =
@@ -130,7 +130,7 @@
         throw new UnprocessableEntityException(
             String.format("Account Inactive: %s", nameOrEmailOrId));
       }
-      newMemberIds.add(a.getId());
+      newMemberIds.add(a.id());
     }
 
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
@@ -139,14 +139,14 @@
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
     }
-    return toAccountInfoList(newMemberIds);
+    return Response.ok(toAccountInfoList(newMemberIds));
   }
 
   Account findAccount(String nameOrEmailOrId)
       throws UnprocessableEntityException, IOException, ConfigInvalidException {
     AccountResolver.Result result = accountResolver.resolve(nameOrEmailOrId);
     try {
-      return result.asUnique().getAccount();
+      return result.asUnique().account();
     } catch (UnresolvableAccountException e) {
       switch (authType) {
         case HTTP_LDAP:
@@ -193,7 +193,7 @@
       req.setSkipAuthentication(true);
       return accountCache
           .get(accountManager.authenticate(req).getAccountId())
-          .map(AccountState::getAccount);
+          .map(AccountState::account);
     } catch (AccountException e) {
       return Optional.empty();
     }
@@ -221,15 +221,14 @@
     }
 
     @Override
-    public AccountInfo apply(GroupResource resource, IdString id, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
-            ConfigInvalidException, PermissionBackendException {
+    public Response<AccountInfo> apply(GroupResource resource, IdString id, Input input)
+        throws Exception {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id.get();
       try {
-        List<AccountInfo> list = put.apply(resource, in);
+        List<AccountInfo> list = put.apply(resource, in).value();
         if (list.size() == 1) {
-          return list.get(0);
+          return Response.created(list.get(0));
         }
         throw new IllegalStateException();
       } catch (UnprocessableEntityException e) {
@@ -248,7 +247,7 @@
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, Input input)
+    public Response<AccountInfo> apply(MemberResource resource, Input input)
         throws PermissionBackendException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 5c879e7..3fd3f29 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -19,17 +19,17 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResolver;
@@ -91,7 +91,7 @@
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource resource, Input input)
+  public Response<List<GroupInfo>> apply(GroupResource resource, Input input)
       throws NotInternalGroupException, AuthException, UnprocessableEntityException,
           ResourceNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
@@ -118,7 +118,7 @@
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
     }
-    return result;
+    return Response.ok(result);
   }
 
   private void addSubgroups(
@@ -142,15 +142,14 @@
     }
 
     @Override
-    public GroupInfo apply(GroupResource resource, IdString id, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
-            ConfigInvalidException, PermissionBackendException {
+    public Response<GroupInfo> apply(GroupResource resource, IdString id, Input input)
+        throws Exception {
       AddSubgroups.Input in = new AddSubgroups.Input();
       in.groups = ImmutableList.of(id.get());
       try {
-        List<GroupInfo> list = addSubgroups.apply(resource, in);
+        List<GroupInfo> list = addSubgroups.apply(resource, in).value();
         if (list.size() == 1) {
-          return list.get(0);
+          return Response.created(list.get(0));
         }
         throw new IllegalStateException();
       } catch (UnprocessableEntityException e) {
@@ -169,7 +168,7 @@
     }
 
     @Override
-    public GroupInfo apply(SubgroupResource resource, Input input)
+    public Response<GroupInfo> apply(SubgroupResource resource, Input input)
         throws PermissionBackendException {
       // Do nothing, the group is already included.
       return get.get().apply(resource);
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index eaedcd7..7c5e7ed 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -19,6 +19,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -29,12 +31,11 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UserInitiated;
@@ -122,7 +123,7 @@
   }
 
   @Override
-  public GroupInfo apply(TopLevelResource resource, IdString id, GroupInput input)
+  public Response<GroupInfo> apply(TopLevelResource resource, IdString id, GroupInput input)
       throws AuthException, BadRequestException, UnprocessableEntityException,
           ResourceConflictException, IOException, ConfigInvalidException, ResourceNotFoundException,
           PermissionBackendException {
@@ -137,6 +138,16 @@
     AccountGroup.UUID ownerUuid = owner(input);
     CreateGroupArgs args = new CreateGroupArgs();
     args.setGroupName(name);
+    args.uuid = Strings.isNullOrEmpty(input.uuid) ? null : AccountGroup.UUID.parse(input.uuid);
+    if (args.uuid != null) {
+      if (!AccountGroup.isInternalGroup(args.uuid)) {
+        throw new BadRequestException(String.format("invalid group UUID '%s'", args.uuid.get()));
+      }
+      if (groupCache.get(args.uuid).isPresent()) {
+        throw new ResourceConflictException(
+            String.format("group with UUID '%s' already exists", args.uuid.get()));
+      }
+    }
     args.groupDescription = Strings.emptyToNull(input.description);
     args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll, defaultVisibleToAll);
     args.ownerGroupUuid = ownerUuid;
@@ -148,7 +159,7 @@
           throw new UnprocessableEntityException(
               String.format("Account Inactive: %s", nameOrEmailOrId));
         }
-        members.add(a.getId());
+        members.add(a.id());
       }
       args.initialMembers = members;
     } else {
@@ -165,7 +176,7 @@
       throw new ResourceConflictException(e.getMessage(), e);
     }
 
-    return json.format(new InternalGroupDescription(createGroup(args)));
+    return Response.created(json.format(new InternalGroupDescription(createGroup(args))));
   }
 
   private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
@@ -193,11 +204,13 @@
       }
     }
 
-    AccountGroup.Id groupId = new AccountGroup.Id(sequences.nextGroupId());
+    AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId());
     AccountGroup.UUID uuid =
-        GroupUUID.make(
-            createGroupArgs.getGroupName(),
-            self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
+        MoreObjects.firstNonNull(
+            createGroupArgs.uuid,
+            GroupUUID.make(
+                createGroupArgs.getGroupName(),
+                self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index b5771a3..b9fd000 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -23,8 +25,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.GroupControl;
@@ -68,7 +68,7 @@
 
     Set<Account.Id> membersToRemove = new HashSet<>();
     for (String nameOrEmail : input.members) {
-      membersToRemove.add(accountResolver.resolve(nameOrEmail).asUnique().getAccount().getId());
+      membersToRemove.add(accountResolver.resolve(nameOrEmail).asUnique().account().id());
     }
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
     try {
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index 9ecfa1f..b9d6ca8 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResolver;
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 1a781d9..508547d 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -17,15 +17,16 @@
 import static java.util.Comparator.comparing;
 
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroupByIdAudit;
+import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
@@ -74,7 +75,7 @@
   }
 
   @Override
-  public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
+  public Response<List<? extends GroupAuditEventInfo>> apply(GroupResource rsrc)
       throws AuthException, NotInternalGroupException, IOException, ConfigInvalidException,
           PermissionBackendException {
     GroupDescription.Internal group =
@@ -90,22 +91,24 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       for (AccountGroupMemberAudit auditEvent :
           groups.getMembersAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountInfo member = accountLoader.get(auditEvent.getMemberId());
+        AccountInfo member = accountLoader.get(auditEvent.memberId());
 
         auditEvents.add(
             GroupAuditEventInfo.createAddUserEvent(
-                accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
 
-      for (AccountGroupByIdAud auditEvent :
+      for (AccountGroupByIdAudit auditEvent :
           groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
+        AccountGroup.UUID includedGroupUUID = auditEvent.includeUuid();
         Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
         GroupInfo member;
         if (includedGroup.isPresent()) {
@@ -121,14 +124,14 @@
 
         auditEvents.add(
             GroupAuditEventInfo.createAddGroupEvent(
-                accountLoader.get(auditEvent.getAddedBy()),
-                auditEvent.getKey().getAddedOn(),
-                member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
     }
@@ -137,6 +140,6 @@
 
     // sort by date and then reverse so that the newest audit event comes first
     auditEvents.sort(comparing((GroupAuditEventInfo a) -> a.date).reversed());
-    return auditEvents;
+    return Response.ok(auditEvents);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetDescription.java b/java/com/google/gerrit/server/restapi/group/GetDescription.java
index c34fda7..b770281 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDescription.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -23,9 +24,9 @@
 @Singleton
 public class GetDescription implements RestReadView<GroupResource> {
   @Override
-  public String apply(GroupResource resource) throws NotInternalGroupException {
+  public Response<String> apply(GroupResource resource) throws NotInternalGroupException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
-    return Strings.nullToEmpty(group.getDescription());
+    return Response.ok(Strings.nullToEmpty(group.getDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetDetail.java b/java/com/google/gerrit/server/restapi/group/GetDetail.java
index c757383..f6b8930 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDetail.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource rsrc) throws PermissionBackendException {
-    return json.format(rsrc);
+  public Response<GroupInfo> apply(GroupResource rsrc) throws PermissionBackendException {
+    return Response.ok(json.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetGroup.java b/java/com/google/gerrit/server/restapi/group/GetGroup.java
index 3ae447b..4785d25 100644
--- a/java/com/google/gerrit/server/restapi/group/GetGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetGroup.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource) throws PermissionBackendException {
-    return json.format(resource.getGroup());
+  public Response<GroupInfo> apply(GroupResource resource) throws PermissionBackendException {
+    return Response.ok(json.format(resource.getGroup()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetMember.java b/java/com/google/gerrit/server/restapi/group/GetMember.java
index 63a8a1b..8dbcd27 100644
--- a/java/com/google/gerrit/server/restapi/group/GetMember.java
+++ b/java/com/google/gerrit/server/restapi/group/GetMember.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.group.MemberResource;
@@ -32,10 +33,10 @@
   }
 
   @Override
-  public AccountInfo apply(MemberResource rsrc) throws PermissionBackendException {
+  public Response<AccountInfo> apply(MemberResource rsrc) throws PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getMember().getAccountId());
     loader.fill();
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetName.java b/java/com/google/gerrit/server/restapi/group/GetName.java
index 8cc1fe0..131dbe4 100644
--- a/java/com/google/gerrit/server/restapi/group/GetName.java
+++ b/java/com/google/gerrit/server/restapi/group/GetName.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetName implements RestReadView<GroupResource> {
 
   @Override
-  public String apply(GroupResource resource) {
-    return resource.getName();
+  public Response<String> apply(GroupResource resource) {
+    return Response.ok(resource.getName());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetOptions.java b/java/com/google/gerrit/server/restapi/group/GetOptions.java
index e5bfe30..5d8ba02 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOptions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 public class GetOptions implements RestReadView<GroupResource> {
 
   @Override
-  public GroupOptionsInfo apply(GroupResource resource) {
-    return GroupJson.createOptions(resource.getGroup());
+  public Response<GroupOptionsInfo> apply(GroupResource resource) {
+    return Response.ok(GroupJson.createOptions(resource.getGroup()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
index 10e1b23..e8bdfaa 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
@@ -38,13 +39,13 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource)
+  public Response<GroupInfo> apply(GroupResource resource)
       throws NotInternalGroupException, ResourceNotFoundException, PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     try {
       GroupControl c = controlFactory.validateFor(group.getOwnerGroupUUID());
-      return json.format(c.getGroup());
+      return Response.ok(json.format(c.getGroup()));
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(group.getOwnerGroupUUID().get(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
index 4466180..c209511 100644
--- a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(SubgroupResource rsrc) throws PermissionBackendException {
-    return json.format(rsrc.getMemberDescription());
+  public Response<GroupInfo> apply(SubgroupResource rsrc) throws PermissionBackendException {
+    return Response.ok(json.format(rsrc.getMemberDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 12b9d61..99c9df7 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -20,11 +20,11 @@
 import com.google.common.base.Strings;
 import com.google.common.base.Suppliers;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
diff --git a/java/com/google/gerrit/server/restapi/group/Index.java b/java/com/google/gerrit/server/restapi/group/Index.java
index 1267f7a..d64669f 100644
--- a/java/com/google/gerrit/server/restapi/group/Index.java
+++ b/java/com/google/gerrit/server/restapi/group/Index.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 7c52300..1802ea6 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -23,17 +23,18 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -250,18 +251,16 @@
   }
 
   @Override
-  public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource) throws Exception {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
     }
-    return output;
+    return Response.ok(output);
   }
 
-  public List<GroupInfo> get()
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  public List<GroupInfo> get() throws Exception {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
@@ -279,7 +278,7 @@
     }
 
     if (user != null) {
-      return accountGetGroups.apply(new AccountResource(userFactory.create(user)));
+      return accountGetGroups.apply(new AccountResource(userFactory.create(user))).value();
     }
 
     return getAllGroups();
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 75be44c..23f0aa7 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -21,10 +21,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
@@ -65,14 +66,14 @@
   }
 
   @Override
-  public List<AccountInfo> apply(GroupResource resource)
+  public Response<List<AccountInfo>> apply(GroupResource resource)
       throws NotInternalGroupException, PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (recursive) {
-      return getTransitiveMembers(group, resource.getControl());
+      return Response.ok(getTransitiveMembers(group, resource.getControl()));
     }
-    return getDirectMembers(group, resource.getControl());
+    return Response.ok(getDirectMembers(group, resource.getControl()));
   }
 
   public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid)
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index bb72a10..540718f 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -19,10 +19,11 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -45,12 +46,12 @@
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource rsrc)
+  public Response<List<GroupInfo>> apply(GroupResource rsrc)
       throws NotInternalGroupException, PermissionBackendException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(NotInternalGroupException::new);
 
-    return getDirectSubgroups(group, rsrc.getControl());
+    return Response.ok(getDirectSubgroups(group, rsrc.getControl()));
   }
 
   public List<GroupInfo> getDirectSubgroups(
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index c4e6f09..8fe4b20 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -16,13 +16,13 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index adc20d5..9a3c87d 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.NameInput;
@@ -23,8 +24,8 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -45,7 +46,7 @@
   }
 
   @Override
-  public String apply(GroupResource rsrc, NameInput input)
+  public Response<String> apply(GroupResource rsrc, NameInput input)
       throws NotInternalGroupException, AuthException, BadRequestException,
           ResourceConflictException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
@@ -62,11 +63,11 @@
     }
 
     if (internalGroup.getName().equals(newName)) {
-      return newName;
+      return Response.ok(newName);
     }
 
     renameGroup(internalGroup, newName);
-    return newName;
+    return Response.ok(newName);
   }
 
   private void renameGroup(GroupDescription.Internal group, String newName)
@@ -74,7 +75,7 @@
           ConfigInvalidException {
     AccountGroup.UUID groupUuid = group.getGroupUUID();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(newName)).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey(newName)).build();
     try {
       groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
     } catch (NoSuchGroupException e) {
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 747d899..53bf571 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -42,7 +43,7 @@
   }
 
   @Override
-  public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
+  public Response<GroupOptionsInfo> apply(GroupResource resource, GroupOptionsInfo input)
       throws NotInternalGroupException, AuthException, BadRequestException,
           ResourceNotFoundException, IOException, ConfigInvalidException {
     GroupDescription.Internal internalGroup =
@@ -73,6 +74,6 @@
     if (input.visibleToAll) {
       options.visibleToAll = true;
     }
-    return options;
+    return Response.ok(options);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index f766a84..04129af 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -16,15 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
@@ -54,7 +55,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource, OwnerInput input)
+  public Response<GroupInfo> apply(GroupResource resource, OwnerInput input)
       throws ResourceNotFoundException, NotInternalGroupException, AuthException,
           BadRequestException, UnprocessableEntityException, IOException, ConfigInvalidException,
           PermissionBackendException {
@@ -79,6 +80,6 @@
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
     }
-    return json.format(owner);
+    return Response.ok(json.format(owner));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index b8dd28d..a233111 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
@@ -97,7 +98,7 @@
   }
 
   @Override
-  public List<GroupInfo> apply(TopLevelResource resource)
+  public Response<List<GroupInfo>> apply(TopLevelResource resource)
       throws BadRequestException, MethodNotAllowedException, PermissionBackendException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
@@ -129,7 +130,7 @@
       if (!groupInfos.isEmpty() && result.more()) {
         groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
       }
-      return groupInfos;
+      return Response.ok(groupInfos);
     } catch (QueryParseException e) {
       throw new BadRequestException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index 3d101b2..64e38b0 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.BanCommitInput;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.BanCommitResult;
@@ -45,7 +46,7 @@
   }
 
   @Override
-  protected BanResultInfo applyImpl(
+  protected Response<BanResultInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ProjectResource rsrc, BanCommitInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException {
     BanResultInfo r = new BanResultInfo();
@@ -65,7 +66,7 @@
       r.alreadyBanned = transformCommits(result.getAlreadyBannedCommits());
       r.ignored = transformCommits(result.getIgnoredObjectIds());
     }
-    return r;
+    return Response.ok(r);
   }
 
   private static List<String> transformCommits(List<ObjectId> commits) {
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index 5fbb4f8..2d78bb0 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -21,8 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/project/Check.java b/java/com/google/gerrit/server/restapi/project/Check.java
index a6fd764..66a2df4 100644
--- a/java/com/google/gerrit/server/restapi/project/Check.java
+++ b/java/com/google/gerrit/server/restapi/project/Check.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -40,9 +41,9 @@
   }
 
   @Override
-  public CheckProjectResultInfo apply(ProjectResource rsrc, CheckProjectInput input)
+  public Response<CheckProjectResultInfo> apply(ProjectResource rsrc, CheckProjectInput input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
-    return projectsConsistencyChecker.check(rsrc.getNameKey(), input);
+    return Response.ok(projectsConsistencyChecker.check(rsrc.getNameKey(), input));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 67b68b5..037a953 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
+import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
@@ -59,7 +60,7 @@
   }
 
   @Override
-  public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
 
@@ -72,7 +73,7 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().getAccount().getId();
+    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
     AccessCheckInfo info = new AccessCheckInfo();
     try {
@@ -83,7 +84,7 @@
     } catch (AuthException e) {
       info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
       info.status = HttpServletResponse.SC_FORBIDDEN;
-      return info;
+      return Response.ok(info);
     }
 
     RefPermission refPerm;
@@ -106,7 +107,7 @@
       try {
         permissionBackend
             .absentUser(match)
-            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
+            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
             .check(refPerm);
       } catch (AuthException e) {
         info.status = HttpServletResponse.SC_FORBIDDEN;
@@ -114,7 +115,7 @@
             String.format(
                 "user %s lacks permission %s for %s in project %s",
                 match, input.permission, input.ref, rsrc.getName());
-        return info;
+        return Response.ok(info);
       }
     } else {
       // We say access is okay if there are no refs, but this warrants a warning,
@@ -126,6 +127,6 @@
       }
     }
     info.status = HttpServletResponse.SC_OK;
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
index 770e8c3..6aaa678 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,7 +49,7 @@
   }
 
   @Override
-  public AccessCheckInfo apply(ProjectResource rsrc)
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
 
     AccessCheckInput input = new AccessCheckInput();
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index de2ac64..4864fde 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -75,7 +77,7 @@
   }
 
   @Override
-  public MergeableInfo apply(BranchResource resource)
+  public Response<MergeableInfo> apply(BranchResource resource)
       throws IOException, BadRequestException, ResourceNotFoundException {
     if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
         || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
@@ -106,7 +108,7 @@
         result.mergeable = true;
         result.commitMerged = true;
         result.contentMerged = true;
-        return result;
+        return Response.ok(result);
       }
 
       if (m.merge(false, targetCommit, sourceCommit)) {
@@ -119,9 +121,9 @@
           result.conflicts = ((ResolveMerger) m).getUnmergedPaths();
         }
       }
-    } catch (IllegalArgumentException e) {
+    } catch (InvalidMergeStrategyException e) {
       throw new BadRequestException(e.getMessage());
     }
-    return result;
+    return Response.ok(result);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
index 8cc8298..a4a82ce 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.IncludedIn;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.inject.Inject;
@@ -35,9 +36,9 @@
   }
 
   @Override
-  public IncludedInInfo apply(CommitResource rsrc) throws RestApiException, IOException {
+  public Response<IncludedInInfo> apply(CommitResource rsrc) throws RestApiException, IOException {
     RevCommit commit = rsrc.getCommit();
     Project.NameKey project = rsrc.getProjectState().getNameKey();
-    return includedIn.apply(project, commit.getId().getName());
+    return Response.ok(includedIn.apply(project, commit.getId().getName()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index d6c4469..d5380c6 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -16,14 +16,16 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.base.Throwables;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.project.CommitResource;
@@ -32,6 +34,9 @@
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.Action;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,6 +54,7 @@
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
   private final DynamicMap<RestView<CommitResource>> views;
   private final GitRepositoryManager repoManager;
+  private final RetryHelper retryHelper;
   private final ChangeIndexCollection indexes;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Reachable reachable;
@@ -57,11 +63,13 @@
   public CommitsCollection(
       DynamicMap<RestView<CommitResource>> views,
       GitRepositoryManager repoManager,
+      RetryHelper retryHelper,
       ChangeIndexCollection indexes,
       Provider<InternalChangeQuery> queryProvider,
       Reachable reachable) {
     this.views = views;
     this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
     this.indexes = indexes;
     this.queryProvider = queryProvider;
     this.reachable = reachable;
@@ -115,7 +123,13 @@
     // Check first if any change references the commit in question. This is much cheaper than ref
     // visibility filtering and reachability computation.
     List<ChangeData> changes =
-        queryProvider.get().enforceVisibility(true).setLimit(1).byProjectCommit(project, commit);
+        executeIndexQuery(
+            () ->
+                queryProvider
+                    .get()
+                    .enforceVisibility(true)
+                    .setLimit(1)
+                    .byProjectCommit(project, commit));
     if (!changes.isEmpty()) {
       return true;
     }
@@ -128,4 +142,14 @@
             .collect(toImmutableList());
     return reachable.fromRefs(project, repo, commit, refs);
   }
+
+  private <T> T executeIndexQuery(Action<T> action) {
+    try {
+      return retryHelper.execute(
+          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new StorageException(e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 37bc265..5deace9 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -17,6 +17,8 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
@@ -25,8 +27,6 @@
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 0741377..fe48301 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -16,6 +16,10 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -24,10 +28,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
@@ -112,7 +112,7 @@
     List<AccessSection> additions = setAccess.getAccessSections(input.add);
 
     Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
+        input.parent == null ? null : Project.nameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
@@ -134,11 +134,10 @@
 
       md.setMessage("Review access change");
       md.setInsertChangeId(true);
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
 
       RevCommit commit =
-          config.commitToNewRef(
-              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+          config.commitToNewRef(md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
 
       if (commit.name().equals(oldCommitSha1)) {
         throw new BadRequestException("no change");
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 01e0cdf..fd6e024 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -14,19 +14,20 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -82,7 +83,7 @@
   }
 
   @Override
-  public BranchInfo apply(ProjectResource rsrc, IdString id, BranchInput input)
+  public Response<BranchInfo> apply(ProjectResource rsrc, IdString id, BranchInput input)
       throws BadRequestException, AuthException, ResourceConflictException, IOException,
           PermissionBackendException, NoSuchProjectException {
     String ref = id.get();
@@ -112,7 +113,7 @@
               + "\"");
     }
 
-    final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
+    final BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -144,7 +145,7 @@
           case NEW:
           case NO_CHANGE:
             referenceUpdated.fire(
-                name.getParentKey(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+                name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
@@ -182,7 +183,7 @@
         info.ref = ref;
         info.revision = revid.getName();
 
-        if (isConfigRef(name.get())) {
+        if (isConfigRef(name.branch())) {
           // Never allow to delete the meta config branch.
           info.canDelete = null;
         } else {
@@ -192,7 +193,7 @@
                   ? true
                   : null;
         }
-        return info;
+        return Response.created(info);
       } catch (IOException err) {
         logger.atSevere().withCause(err).log("Cannot create branch \"%s\"", name);
         throw err;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index e8b6236..314df73 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -19,15 +19,12 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
 @Singleton
@@ -45,14 +42,16 @@
 
   @Override
   public Response<DashboardInfo> apply(ProjectResource parent, IdString id, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     parent.getProjectState().checkStatePermitsWrite();
     if (!DashboardsCollection.isDefaultDashboard(id)) {
       throw new ResourceNotFoundException(id);
     }
     SetDefaultDashboard set = setDefault.get();
     set.inherited = inherited;
-    return set.apply(
-        DashboardResource.projectDefault(parent.getProjectState(), parent.getUser()), input);
+    return Response.created(
+        set.apply(
+                DashboardResource.projectDefault(parent.getProjectState(), parent.getUser()), input)
+            .value());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 8b81f10..a5a0034 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -33,7 +34,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 6a04e0d..5cfb118 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.server.WebLinks;
@@ -78,7 +79,7 @@
   }
 
   @Override
-  public TagInfo apply(ProjectResource resource, IdString id, TagInput input)
+  public Response<TagInfo> apply(ProjectResource resource, IdString id, TagInput input)
       throws RestApiException, IOException, PermissionBackendException, NoSuchProjectException {
     String ref = id.get();
     if (input == null) {
@@ -146,7 +147,8 @@
             result.getObjectId(),
             resource.getUser().asIdentifiedUser().state());
         try (RevWalk w = new RevWalk(repo)) {
-          return ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links);
+          return Response.created(
+              ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
         }
       }
     } catch (InvalidRevisionException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index 4dc83e8..ca48109 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.entities.RefNames.REFS_DASHBOARDS;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -33,7 +34,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index b94fb51..6248a61 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -47,12 +47,12 @@
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
       throws RestApiException, IOException, PermissionBackendException {
-    if (RefNames.HEAD.equals(rsrc.getBranchKey().get())) {
+    if (RefNames.HEAD.equals(rsrc.getBranchKey().branch())) {
       throw new MethodNotAllowedException("not allowed to delete HEAD");
-    } else if (isConfigRef(rsrc.getBranchKey().get())) {
+    } else if (isConfigRef(rsrc.getBranchKey().branch())) {
       // Never allow to delete the meta config branch.
       throw new MethodNotAllowedException(
-          "not allowed to delete branch " + rsrc.getBranchKey().get());
+          "not allowed to delete branch " + rsrc.getBranchKey().branch());
     }
 
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
index ba25e33..ca5962e 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -17,13 +17,13 @@
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
index 2702d58..9d9e5f5 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -18,14 +18,11 @@
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 
 @Singleton
 public class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
@@ -38,7 +35,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (resource.isProjectDefault()) {
       SetDashboardInput in = new SetDashboardInput();
       in.commitMessage = input != null ? input.commitMessage : null;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index dae759a..1979d61 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 import static java.lang.String.format;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -25,11 +25,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -260,7 +260,7 @@
     }
 
     if (!refName.startsWith(R_TAGS)) {
-      Branch.NameKey branchKey = new Branch.NameKey(projectState.getNameKey(), ref.getName());
+      BranchNameKey branchKey = BranchNameKey.create(projectState.getNameKey(), ref.getName());
       if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
         command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
       }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 33955ee..545b752 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 09f973b..0ee8279 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListKey;
@@ -32,6 +34,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.kohsuke.args4j.Option;
 
@@ -82,7 +85,8 @@
     }
 
     @Override
-    public Object apply(CommitResource resource) throws PatchListNotAvailableException {
+    public Response<Map<String, FileInfo>> apply(CommitResource resource)
+        throws PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       PatchListKey key;
 
@@ -94,7 +98,7 @@
         key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
       }
 
-      return fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key);
+      return Response.ok(fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index 23115de..25a2c90 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -19,13 +19,13 @@
 
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -71,12 +71,12 @@
   }
 
   @Override
-  public Object apply(ProjectResource rsrc, Input input) {
+  public Response<?> apply(ProjectResource rsrc, Input input) {
     Project.NameKey project = rsrc.getNameKey();
     if (input.async) {
       return applyAsync(project, input);
     }
-    return applySync(project, input);
+    return Response.ok(applySync(project, input));
   }
 
   private Response.Accepted applyAsync(Project.NameKey project, Input input) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index d6c07dd..a9a9403 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -29,6 +29,9 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
@@ -37,10 +40,8 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupBackend;
@@ -116,18 +117,16 @@
     this.projectConfigFactory = projectConfigFactory;
   }
 
-  public ProjectAccessInfo apply(Project.NameKey nameKey)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException {
+  public ProjectAccessInfo apply(Project.NameKey nameKey) throws Exception {
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       throw new ResourceNotFoundException(nameKey.get());
     }
-    return apply(new ProjectResource(state, user.get()));
+    return apply(new ProjectResource(state, user.get())).value();
   }
 
   @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc)
+  public Response<ProjectAccessInfo> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
           PermissionBackendException {
     // Load the current configuration from the repository, ensuring it's the most
@@ -275,7 +274,7 @@
             .filter(e -> e.getValue() != null)
             .collect(toMap(e -> e.getKey().get(), Map.Entry::getValue));
 
-    return info;
+    return Response.ok(info);
   }
 
   private void loadGroup(Map<AccountGroup.UUID, GroupInfo> groups, AccountGroup.UUID id) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetBranch.java b/java/com/google/gerrit/server/restapi/project/GetBranch.java
index 7d32f3d..52a47a4 100644
--- a/java/com/google/gerrit/server/restapi/project/GetBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/GetBranch.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
@@ -34,8 +35,8 @@
   }
 
   @Override
-  public BranchInfo apply(BranchResource rsrc)
+  public Response<BranchInfo> apply(BranchResource rsrc)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
-    return list.get().toBranchInfo(rsrc);
+    return Response.ok(list.get().toBranchInfo(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetChildProject.java b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
index e69907e..b90f6ee 100644
--- a/java/com/google/gerrit/server/restapi/project/GetChildProject.java
+++ b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.ProjectJson;
@@ -37,9 +38,9 @@
   }
 
   @Override
-  public ProjectInfo apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
+  public Response<ProjectInfo> apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
     if (recursive || rsrc.isDirectChild()) {
-      return json.format(rsrc.getChild().getProject());
+      return Response.ok(json.format(rsrc.getChild().getProject()));
     }
     throw new ResourceNotFoundException(rsrc.getChild().getName());
   }
diff --git a/java/com/google/gerrit/server/restapi/project/GetCommit.java b/java/com/google/gerrit/server/restapi/project/GetCommit.java
index 1c1ae90..cca6a1a 100644
--- a/java/com/google/gerrit/server/restapi/project/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/GetCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.project.CommitResource;
@@ -25,7 +26,7 @@
 public class GetCommit implements RestReadView<CommitResource> {
 
   @Override
-  public CommitInfo apply(CommitResource rsrc) throws IOException {
-    return CommitUtil.toCommitInfo(rsrc.getCommit());
+  public Response<CommitInfo> apply(CommitResource rsrc) throws IOException {
+    return Response.ok(CommitUtil.toCommitInfo(rsrc.getCommit()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index b3ad962..ce45e7d 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.EnableSignedPush;
@@ -53,15 +54,16 @@
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfoImpl(
-        serverEnableSignedPush,
-        resource.getProjectState(),
-        resource.getUser(),
-        pluginConfigEntries,
-        cfgFactory,
-        allProjects,
-        uiActions,
-        views);
+  public Response<ConfigInfo> apply(ProjectResource resource) {
+    return Response.ok(
+        new ConfigInfoImpl(
+            serverEnableSignedPush,
+            resource.getProjectState(),
+            resource.getUser(),
+            pluginConfigEntries,
+            cfgFactory,
+            allProjects,
+            uiActions,
+            views));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetContent.java b/java/com/google/gerrit/server/restapi/project/GetContent.java
index 132b644..4e3fc8e 100644
--- a/java/com/google/gerrit/server/restapi/project/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/project/GetContent.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.project.FileResource;
@@ -34,8 +35,9 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
+  public Response<BinaryResult> apply(FileResource rsrc)
       throws ResourceNotFoundException, BadRequestException, IOException {
-    return fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
+    return Response.ok(
+        fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetDashboard.java b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
index 2ec67e7..928a36f 100644
--- a/java/com/google/gerrit/server/restapi/project/GetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.entities.RefNames.REFS_DASHBOARDS;
 import static com.google.gerrit.server.restapi.project.DashboardsCollection.isDefaultDashboard;
 
 import com.google.common.base.Splitter;
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
@@ -56,7 +57,7 @@
   }
 
   @Override
-  public DashboardInfo apply(DashboardResource rsrc)
+  public Response<DashboardInfo> apply(DashboardResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     if (inherited && !rsrc.isProjectDefault()) {
       throw new BadRequestException("inherited flag can only be used with default");
@@ -71,13 +72,14 @@
       }
     }
 
-    return DashboardsCollection.parse(
-        rsrc.getProjectState().getProject(),
-        rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
-        rsrc.getPathName(),
-        rsrc.getConfig(),
-        rsrc.getProjectState().getName(),
-        true);
+    return Response.ok(
+        DashboardsCollection.parse(
+            rsrc.getProjectState().getProject(),
+            rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
+            rsrc.getPathName(),
+            rsrc.getConfig(),
+            rsrc.getProjectState().getName(),
+            true));
   }
 
   private DashboardResource defaultOf(ProjectState projectState, CurrentUser user)
diff --git a/java/com/google/gerrit/server/restapi/project/GetDescription.java b/java/com/google/gerrit/server/restapi/project/GetDescription.java
index d387ff1..2561b91 100644
--- a/java/com/google/gerrit/server/restapi/project/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/GetDescription.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetDescription implements RestReadView<ProjectResource> {
   @Override
-  public String apply(ProjectResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription());
+  public Response<String> apply(ProjectResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetHead.java b/java/com/google/gerrit/server/restapi/project/GetHead.java
index 043991f..4e0a144 100644
--- a/java/com/google/gerrit/server/restapi/project/GetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/GetHead.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -52,7 +53,7 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc)
+  public Response<String> apply(ProjectResource rsrc)
       throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
     rsrc.getProjectState().statePermitsRead();
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
@@ -66,12 +67,12 @@
             .project(rsrc.getNameKey())
             .ref(n)
             .check(RefPermission.READ);
-        return n;
+        return Response.ok(n);
       } else if (head.getObjectId() != null) {
         try (RevWalk rw = new RevWalk(repo)) {
           RevCommit commit = rw.parseCommit(head.getObjectId());
           if (commits.canRead(rsrc.getProjectState(), repo, commit)) {
-            return head.getObjectId().name();
+            return Response.ok(head.getObjectId().name());
           }
           throw new AuthException("not allowed to see HEAD");
         } catch (MissingObjectException | IncorrectObjectTypeException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetParent.java b/java/com/google/gerrit/server/restapi/project/GetParent.java
index a4942e3..9b93d5b 100644
--- a/java/com/google/gerrit/server/restapi/project/GetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/GetParent.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -31,9 +32,9 @@
   }
 
   @Override
-  public String apply(ProjectResource resource) {
+  public Response<String> apply(ProjectResource resource) {
     Project project = resource.getProjectState().getProject();
     Project.NameKey parentName = project.getParent(allProjectsName);
-    return parentName != null ? parentName.get() : "";
+    return Response.ok(parentName != null ? parentName.get() : "");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetProject.java b/java/com/google/gerrit/server/restapi/project/GetProject.java
index 26159e4..2f7d370 100644
--- a/java/com/google/gerrit/server/restapi/project/GetProject.java
+++ b/java/com/google/gerrit/server/restapi/project/GetProject.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public ProjectInfo apply(ProjectResource rsrc) {
-    return json.format(rsrc.getProjectState());
+  public Response<ProjectInfo> apply(ProjectResource rsrc) {
+    return Response.ok(json.format(rsrc.getProjectState()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index a690042..f9c6fd9 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
@@ -89,7 +90,7 @@
   }
 
   @Override
-  public List<ReflogEntryInfo> apply(BranchResource rsrc)
+  public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     permissionBackend
         .user(rsrc.getUser())
@@ -123,7 +124,7 @@
           }
         }
       }
-      return Lists.transform(entries, this::newReflogEntryInfo);
+      return Response.ok(Lists.transform(entries, this::newReflogEntryInfo));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/GetStatistics.java b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
index a408062..db97855 100644
--- a/java/com/google/gerrit/server/restapi/project/GetStatistics.java
+++ b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectResource;
@@ -42,11 +43,11 @@
   }
 
   @Override
-  public RepositoryStatistics apply(ProjectResource rsrc)
+  public Response<RepositoryStatistics> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, ResourceConflictException {
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       GarbageCollectCommand gc = Git.wrap(repo).gc();
-      return new RepositoryStatistics(gc.getStatistics());
+      return Response.ok(new RepositoryStatistics(gc.getStatistics()));
     } catch (GitAPIException | JGitInternalException e) {
       throw new ResourceConflictException(e.getMessage());
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetTag.java b/java/com/google/gerrit/server/restapi/project/GetTag.java
index 6d5a510..6ab2f8b 100644
--- a/java/com/google/gerrit/server/restapi/project/GetTag.java
+++ b/java/com/google/gerrit/server/restapi/project/GetTag.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.TagResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 public class GetTag implements RestReadView<TagResource> {
 
   @Override
-  public TagInfo apply(TagResource resource) {
-    return resource.getTagInfo();
+  public Response<TagInfo> apply(TagResource resource) {
+    return Response.ok(resource.getTagInfo());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index bc58b23..b14380a 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -18,21 +18,18 @@
 
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.IndexProjectInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.project.ProjectIndexer;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.concurrent.Future;
 
 @RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
@@ -53,14 +50,14 @@
   }
 
   @Override
-  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input)
-      throws IOException, PermissionBackendException, RestApiException {
+  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input) throws Exception {
     String response = "Project " + rsrc.getName() + " submitted for reindexing";
 
     reindex(rsrc.getNameKey(), input.async);
     if (Boolean.TRUE.equals(input.indexChildren)) {
-      for (ProjectInfo child : listChildProjectsProvider.get().withRecursive(true).apply(rsrc)) {
-        reindex(new Project.NameKey(child.name), input.async);
+      for (ProjectInfo child :
+          listChildProjectsProvider.get().withRecursive(true).apply(rsrc).value()) {
+        reindex(Project.nameKey(child.name), input.async);
       }
 
       response += " (indexing children recursively)";
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
index b6b3d6b..45a6616 100644
--- a/java/com/google/gerrit/server/restapi/project/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -19,11 +19,11 @@
 import com.google.common.io.ByteStreams;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index ae9ef28..fecdc8e 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -26,11 +27,11 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.webui.UiActions;
@@ -127,15 +128,16 @@
   }
 
   @Override
-  public List<BranchInfo> apply(ProjectResource rsrc)
+  public Response<ImmutableList<BranchInfo>> apply(ProjectResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     rsrc.getProjectState().checkStatePermitsRead();
-    return new RefFilter<BranchInfo>(Constants.R_HEADS)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .start(start)
-        .limit(limit)
-        .filter(allBranches(rsrc));
+    return Response.ok(
+        new RefFilter<BranchInfo>(Constants.R_HEADS)
+            .subString(matchSubstring)
+            .regex(matchRegex)
+            .start(start)
+            .limit(limit)
+            .filter(allBranches(rsrc)));
   }
 
   BranchInfo toBranchInfo(BranchResource rsrc)
@@ -278,7 +280,8 @@
       info.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    List<WebLinkInfo> links = webLinks.getBranchLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> links =
+        webLinks.getBranchLinks(projectState.getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 3879720..0bd053e 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -16,12 +16,13 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -65,7 +66,7 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc)
+  public Response<List<ProjectInfo>> apply(ProjectResource rsrc)
       throws PermissionBackendException, RestApiException {
     if (limit < 0) {
       throw new BadRequestException("limit must be a positive number");
@@ -75,20 +76,17 @@
     }
     rsrc.getProjectState().checkStatePermitsRead();
     if (recursive) {
-      return childProjects.list(rsrc.getNameKey());
+      return Response.ok(childProjects.list(rsrc.getNameKey()));
     }
 
-    return directChildProjects(rsrc.getNameKey());
+    return Response.ok(directChildProjects(rsrc.getNameKey()));
   }
 
   private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
     PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
         .filter(
-            p ->
-                currentUser
-                    .project(new Project.NameKey(p.name))
-                    .testOrFalse(ProjectPermission.ACCESS))
+            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index fd8668a..4406719 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.entities.RefNames.REFS_DASHBOARDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -63,11 +64,11 @@
   }
 
   @Override
-  public List<?> apply(ProjectResource rsrc)
+  public Response<List<?>> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     String project = rsrc.getName();
     if (!inherited) {
-      return scan(rsrc.getProjectState(), project, true);
+      return Response.ok(scan(rsrc.getProjectState(), project, true));
     }
 
     List<List<DashboardInfo>> all = new ArrayList<>();
@@ -83,7 +84,7 @@
         all.add(list);
       }
     }
-    return all;
+    return Response.ok(all);
   }
 
   private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c583923..6384282 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -27,6 +27,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -35,13 +38,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupControl;
@@ -303,16 +304,17 @@
   }
 
   @Override
-  public Object apply(TopLevelResource resource)
+  public Response<Object> apply(TopLevelResource resource)
       throws BadRequestException, PermissionBackendException {
     if (format == OutputFormat.TEXT) {
       ByteArrayOutputStream buf = new ByteArrayOutputStream();
       displayToStream(buf);
-      return BinaryResult.create(buf.toByteArray())
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
+      return Response.ok(
+          BinaryResult.create(buf.toByteArray())
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
     }
-    return apply();
+    return Response.ok(apply());
   }
 
   public SortedMap<String, ProjectInfo> apply()
@@ -505,7 +507,7 @@
           continue;
         }
 
-        List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
         info.webLinks = links.isEmpty() ? null : links;
 
         if (stdout == null || format.isJson()) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 08b9b84..ff6d30e5 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -14,18 +14,19 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isConfigRef;
 import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -116,7 +117,7 @@
   }
 
   @Override
-  public List<TagInfo> apply(ProjectResource resource)
+  public Response<ImmutableList<TagInfo>> apply(ProjectResource resource)
       throws IOException, ResourceNotFoundException, RestApiException, PermissionBackendException {
     resource.getProjectState().checkStatePermitsRead();
 
@@ -137,12 +138,13 @@
 
     tags.sort(comparing(t -> t.ref));
 
-    return new RefFilter<TagInfo>(Constants.R_TAGS)
-        .start(start)
-        .limit(limit)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .filter(tags);
+    return Response.ok(
+        new RefFilter<TagInfo>(Constants.R_TAGS)
+            .start(start)
+            .limit(limit)
+            .subString(matchSubstring)
+            .regex(matchRegex)
+            .filter(tags));
   }
 
   public TagInfo get(ProjectResource resource, IdString id)
@@ -182,7 +184,7 @@
           perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
     }
 
-    List<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
       RevTag tag = (RevTag) object;
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index c83e473..1e6200c 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.util.TreeFormatter.TreeNode;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 31c90e5..7d6f7ef 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -30,7 +31,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -138,7 +138,7 @@
       throws IOException, PermissionBackendException, ResourceConflictException {
     id = ProjectUtil.sanitizeProjectName(id);
 
-    Project.NameKey nameKey = new Project.NameKey(id);
+    Project.NameKey nameKey = Project.nameKey(id);
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       return null;
diff --git a/java/com/google/gerrit/server/restapi/project/PutBranch.java b/java/com/google/gerrit/server/restapi/project/PutBranch.java
index fec8abf..02fc6689 100644
--- a/java/com/google/gerrit/server/restapi/project/PutBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/PutBranch.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Singleton;
@@ -25,7 +26,8 @@
 public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
 
   @Override
-  public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
+  public Response<BranchInfo> apply(BranchResource rsrc, BranchInput input)
+      throws ResourceConflictException {
     throw new ResourceConflictException("Branch \"" + rsrc.getRef() + "\" already exists");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 56ddbb4..4aecb4a 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -17,6 +17,8 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -26,11 +28,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -108,13 +109,13 @@
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
+  public Response<ConfigInfo> apply(ProjectResource rsrc, ConfigInput input)
       throws RestApiException, PermissionBackendException {
     permissionBackend
         .currentUser()
         .project(rsrc.getNameKey())
         .check(ProjectPermission.WRITE_CONFIG);
-    return apply(rsrc.getProjectState(), input);
+    return Response.ok(apply(rsrc.getProjectState(), input));
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index c3a063d..a0b9feb 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -16,13 +16,13 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/project/PutTag.java b/java/com/google/gerrit/server/restapi/project/PutTag.java
index 06c5157..b6f3f24 100644
--- a/java/com/google/gerrit/server/restapi/project/PutTag.java
+++ b/java/com/google/gerrit/server/restapi/project/PutTag.java
@@ -17,13 +17,15 @@
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.project.TagResource;
 
 public class PutTag implements RestModifyView<TagResource, TagInput> {
 
   @Override
-  public TagInfo apply(TagResource resource, TagInput input) throws ResourceConflictException {
+  public Response<TagInfo> apply(TagResource resource, TagInput input)
+      throws ResourceConflictException {
     throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref + "\" already exists");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index 8727df3..7066d9a 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.project.ProjectData;
@@ -86,9 +87,9 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(TopLevelResource resource)
+  public Response<List<ProjectInfo>> apply(TopLevelResource resource)
       throws BadRequestException, MethodNotAllowedException {
-    return apply();
+    return Response.ok(apply());
   }
 
   public List<ProjectInfo> apply() throws BadRequestException, MethodNotAllowedException {
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 95bc75f7..02c1b54 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -17,23 +17,20 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -41,7 +38,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -80,9 +76,8 @@
   }
 
   @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException, PermissionBackendException {
+  public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
+      throws Exception {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
     ProjectConfig config;
@@ -117,7 +112,7 @@
           identifiedUser.get(),
           config,
           rsrc.getNameKey(),
-          input.parent == null ? null : new Project.NameKey(input.parent),
+          input.parent == null ? null : Project.nameKey(input.parent),
           !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
@@ -138,6 +133,6 @@
       throw new ResourceConflictException(rsrc.getName(), e);
     }
 
-    return getAccess.apply(rsrc.getNameKey());
+    return Response.ok(getAccess.apply(rsrc.getNameKey()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index e206319..ea29fb3 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
@@ -29,7 +30,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.GroupResolver;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
index 2804b7c..e8e0c0d 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -18,14 +18,11 @@
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 
 @Singleton
 public class SetDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
@@ -38,7 +35,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (resource.isProjectDefault()) {
       return defaultSetter.get().apply(resource, input);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 3917bee..49b5cab 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -23,12 +24,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectCache;
@@ -36,7 +34,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
@@ -70,7 +67,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource rsrc, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (input == null) {
       input = new SetDashboardInput(); // Delete would set input to null.
     }
@@ -119,9 +116,9 @@
       cache.evict(rsrc.getProjectState().getProject());
 
       if (target != null) {
-        DashboardInfo info = get.get().apply(target);
-        info.isDefault = true;
-        return Response.ok(info);
+        Response<DashboardInfo> response = get.get().apply(target);
+        response.value().isDefault = true;
+        return response;
       }
       return Response.none();
     } catch (RepositoryNotFoundException notFound) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetHead.java b/java/com/google/gerrit/server/restapi/project/SetHead.java
index 430d709..0afea5c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/SetHead.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -63,7 +64,7 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc, HeadInput input)
+  public Response<String> apply(ProjectResource rsrc, HeadInput input)
       throws AuthException, ResourceNotFoundException, BadRequestException,
           UnprocessableEntityException, IOException, PermissionBackendException {
     if (input == null || Strings.isNullOrEmpty(input.ref)) {
@@ -109,7 +110,7 @@
 
         fire(rsrc.getNameKey(), oldHead, newHead);
       }
-      return ref;
+      return Response.ok(ref);
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(rsrc.getName(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 8e0363f..a610dd4 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -20,14 +20,15 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -85,11 +86,11 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc, ParentInput input)
+  public Response<String> apply(ProjectResource rsrc, ParentInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
           BadRequestException {
-    return apply(rsrc, input, true);
+    return Response.ok(apply(rsrc, input, true));
   }
 
   public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
@@ -155,7 +156,7 @@
 
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
-      ProjectState parent = cache.get(new Project.NameKey(newParent));
+      ProjectState parent = cache.get(Project.nameKey(newParent));
       if (parent == null) {
         throw new UnprocessableEntityException("parent project " + newParent + " not found");
       }
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 2be6c19..32aec59 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -20,20 +20,19 @@
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Java implementation of Gerrit's default pre-submit rules behavior: check if the labels have the
@@ -63,13 +62,13 @@
   }
 
   @Override
-  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+  public Optional<SubmitRecord> evaluate(ChangeData cd) {
     ProjectState projectState = projectCache.get(cd.project());
 
     // In case at least one project has a rules.pl file, we let Prolog handle it.
     // The Prolog rules engine will also handle the labels for us.
     if (projectState == null || projectState.hasPrologRules()) {
-      return Collections.emptyList();
+      return Optional.empty();
     }
 
     SubmitRecord submitRecord = new SubmitRecord();
@@ -86,7 +85,7 @@
 
       submitRecord.errorMessage = "Unable to fetch labels and approvals for the change";
       submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-      return Collections.singletonList(submitRecord);
+      return Optional.of(submitRecord);
     }
 
     submitRecord.labels = new ArrayList<>(labelTypes.size());
@@ -99,7 +98,7 @@
 
         submitRecord.errorMessage = "Unable to find the LabelFunction for label " + t.getName();
         submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-        return Collections.singletonList(submitRecord);
+        return Optional.of(submitRecord);
       }
 
       Collection<PatchSetApproval> approvalsForLabel = getApprovalsForLabel(approvals, t);
@@ -119,13 +118,13 @@
       }
     }
 
-    return Collections.singletonList(submitRecord);
+    return Optional.of(submitRecord);
   }
 
   private static List<PatchSetApproval> getApprovalsForLabel(
       List<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
-        .filter(input -> input.getLabel().equals(t.getLabelId().get()))
+        .filter(input -> input.label().equals(t.getLabelId().get()))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 592c269c..54625a6 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -17,24 +17,23 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Rule to require an approval from a user that did not upload the current patch set or block
@@ -60,7 +59,7 @@
   IgnoreSelfApprovalRule() {}
 
   @Override
-  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+  public Optional<SubmitRecord> evaluate(ChangeData cd) {
     List<LabelType> labelTypes;
     List<PatchSetApproval> approvals;
     try {
@@ -68,21 +67,21 @@
       approvals = cd.currentApprovals();
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
-      return singletonRuleError(E_UNABLE_TO_FETCH_LABELS);
+      return ruleError(E_UNABLE_TO_FETCH_LABELS);
     }
 
     boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(LabelType::ignoreSelfApproval);
     if (!shouldIgnoreSelfApproval) {
       // Shortcut to avoid further processing if no label should ignore uploader approvals
-      return ImmutableList.of();
+      return Optional.empty();
     }
 
     Account.Id uploader;
     try {
-      uploader = cd.currentPatchSet().getUploader();
+      uploader = cd.currentPatchSet().uploader();
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
-      return singletonRuleError(E_UNABLE_TO_FETCH_UPLOADER);
+      return ruleError(E_UNABLE_TO_FETCH_UPLOADER);
     }
 
     SubmitRecord submitRecord = new SubmitRecord();
@@ -124,10 +123,10 @@
     }
 
     if (submitRecord.labels.isEmpty()) {
-      return ImmutableList.of();
+      return Optional.empty();
     }
 
-    return ImmutableList.of(submitRecord);
+    return Optional.of(submitRecord);
   }
 
   private static boolean labelCheckPassed(SubmitRecord.Label label) {
@@ -144,18 +143,18 @@
     return false;
   }
 
-  private static Collection<SubmitRecord> singletonRuleError(String reason) {
+  private static Optional<SubmitRecord> ruleError(String reason) {
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.errorMessage = reason;
     submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-    return ImmutableList.of(submitRecord);
+    return Optional.of(submitRecord);
   }
 
   @VisibleForTesting
   static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
       Collection<PatchSetApproval> approvals, Account.Id user) {
     return approvals.stream()
-        .filter(input -> input.getValue() < 0 || !input.getAccountId().equals(user))
+        .filter(input -> input.value() < 0 || !input.accountId().equals(user))
         .collect(toImmutableList());
   }
 
@@ -163,7 +162,7 @@
   static Collection<PatchSetApproval> filterApprovalsByLabel(
       Collection<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
-        .filter(input -> input.getLabelId().get().equals(t.getLabelId().get()))
+        .filter(input -> input.labelId().get().equals(t.getLabelId().get()))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/PrologOptions.java
new file mode 100644
index 0000000..da9b3ab
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologOptions.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+@AutoValue
+public abstract class PrologOptions {
+  public static PrologOptions defaultOptions() {
+    return new AutoValue_PrologOptions.Builder().logErrors(true).skipFilters(false).build();
+  }
+
+  public static PrologOptions dryRunOptions(String ruleToTest, boolean skipFilters) {
+    return new AutoValue_PrologOptions.Builder()
+        .logErrors(false)
+        .skipFilters(skipFilters)
+        .rule(ruleToTest)
+        .build();
+  }
+
+  /** Whether errors should be logged. */
+  abstract boolean logErrors();
+
+  /** Whether Prolog filters from parent projects should be skipped. */
+  abstract boolean skipFilters();
+
+  /**
+   * Prolog rule that should be run. If not given, the Prolog rule that is configured for the
+   * project is used (the rule from rules.pl in refs/meta/config).
+   */
+  abstract Optional<String> rule();
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract PrologOptions.Builder logErrors(boolean logErrors);
+
+    abstract PrologOptions.Builder skipFilters(boolean skipFilters);
+
+    abstract PrologOptions.Builder rule(@Nullable String rule);
+
+    abstract PrologOptions build();
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index 0c54f40..bf1d545 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -18,12 +18,10 @@
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.Collections;
+import java.util.Optional;
 
 @Singleton
 public class PrologRule implements SubmitRule {
@@ -37,21 +35,29 @@
   }
 
   @Override
-  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions opts) {
+  public Optional<SubmitRecord> evaluate(ChangeData cd) {
     ProjectState projectState = projectCache.get(cd.project());
     // We only want to run the Prolog engine if we have at least one rules.pl file to use.
-    if ((projectState == null || !projectState.hasPrologRules()) && opts.rule() == null) {
-      return Collections.emptyList();
+    if ((projectState == null || !projectState.hasPrologRules())) {
+      return Optional.empty();
     }
 
+    return Optional.of(evaluate(cd, PrologOptions.defaultOptions()));
+  }
+
+  public SubmitRecord evaluate(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).evaluate();
   }
 
-  private PrologRuleEvaluator getEvaluator(ChangeData cd, SubmitRuleOptions opts) {
-    return factory.create(cd, opts);
+  public SubmitTypeRecord getSubmitType(ChangeData cd) {
+    return getSubmitType(cd, PrologOptions.defaultOptions());
   }
 
-  public SubmitTypeRecord getSubmitType(ChangeData cd, SubmitRuleOptions opts) {
+  public SubmitTypeRecord getSubmitType(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).getSubmitType();
   }
+
+  private PrologRuleEvaluator getEvaluator(ChangeData cd, PrologOptions opts) {
+    return factory.create(cd, opts);
+  }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 9cde54c..72dc46a 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.createRuleError;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
@@ -24,10 +25,10 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.Emails;
@@ -35,7 +36,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RuleEvalException;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -51,7 +51,6 @@
 import com.googlecode.prolog_cafe.lang.VariableTerm;
 import java.io.StringReader;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
@@ -73,7 +72,7 @@
 
   public interface Factory {
     /** Returns a new {@link PrologRuleEvaluator} with the specified options */
-    PrologRuleEvaluator create(ChangeData cd, SubmitRuleOptions options);
+    PrologRuleEvaluator create(ChangeData cd, PrologOptions options);
   }
 
   /**
@@ -95,7 +94,7 @@
   private final PrologEnvironment.Factory envFactory;
   private final ChangeData cd;
   private final ProjectState projectState;
-  private final SubmitRuleOptions opts;
+  private final PrologOptions opts;
   private Term submitRule;
 
   @AssistedInject
@@ -107,7 +106,7 @@
       PrologEnvironment.Factory envFactory,
       ProjectCache projectCache,
       @Assisted ChangeData cd,
-      @Assisted SubmitRuleOptions options) {
+      @Assisted PrologOptions options) {
     this.accountCache = accountCache;
     this.accounts = accounts;
     this.emails = emails;
@@ -141,10 +140,9 @@
   /**
    * Evaluate the submit rules.
    *
-   * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
-   *     errors.
+   * @return {@link SubmitRecord} returned from the evaluated rules. Can include errors.
    */
-  public Collection<SubmitRecord> evaluate() {
+  public SubmitRecord evaluate() {
     Change change;
     try {
       change = cd.change();
@@ -159,12 +157,6 @@
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
-    if (!opts.allowClosed() && change.isClosed()) {
-      SubmitRecord rec = new SubmitRecord();
-      rec.status = SubmitRecord.Status.CLOSED;
-      return Collections.singletonList(rec);
-    }
-
     List<Term> results;
     try {
       results =
@@ -200,28 +192,32 @@
    * output. Later after the loop the out collection is reversed to restore it to the original
    * ordering.
    */
-  public List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
-    boolean foundOk = false;
-    List<SubmitRecord> out = new ArrayList<>(results.size());
+  public SubmitRecord resultsToSubmitRecord(Term submitRule, List<Term> results) {
+    checkState(!results.isEmpty(), "the list of Prolog terms must not be empty");
+
+    SubmitRecord resultSubmitRecord = new SubmitRecord();
+    resultSubmitRecord.labels = new ArrayList<>();
     for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
       Term submitRecord = results.get(resultIdx);
-      SubmitRecord rec = new SubmitRecord();
-      out.add(rec);
 
       if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
         return invalidResult(submitRule, submitRecord);
       }
 
-      if ("ok".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.OK;
-
-      } else if ("not_ready".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.NOT_READY;
-
-      } else {
+      if (!"ok".equals(submitRecord.name()) && !"not_ready".equals(submitRecord.name())) {
         return invalidResult(submitRule, submitRecord);
       }
 
+      // This transformation is required to adapt Prolog's behavior to the way Gerrit handles
+      // SubmitRecords, as defined in the SubmitRecord#allRecordsOK method.
+      // When several rules are defined in Prolog, they are all matched to a SubmitRecord. We want
+      // the change to be submittable when at least one result is OK.
+      if ("ok".equals(submitRecord.name())) {
+        resultSubmitRecord.status = SubmitRecord.Status.OK;
+      } else if ("not_ready".equals(submitRecord.name()) && resultSubmitRecord.status == null) {
+        resultSubmitRecord.status = SubmitRecord.Status.NOT_READY;
+      }
+
       // Unpack the one argument. This should also be a structure with one
       // argument per label that needs to be reported on to the caller.
       //
@@ -231,8 +227,6 @@
         return invalidResult(submitRule, submitRecord);
       }
 
-      rec.labels = new ArrayList<>(submitRecord.arity());
-
       for (Term state : ((StructureTerm) submitRecord).args()) {
         if (!(state instanceof StructureTerm)
             || 2 != state.arity()
@@ -241,7 +235,7 @@
         }
 
         SubmitRecord.Label lbl = new SubmitRecord.Label();
-        rec.labels.add(lbl);
+        resultSubmitRecord.labels.add(lbl);
 
         lbl.label = checkLabelName(state.arg(0).name());
         Term status = state.arg(1);
@@ -272,24 +266,12 @@
         }
       }
 
-      if (rec.status == SubmitRecord.Status.OK) {
-        foundOk = true;
+      if (resultSubmitRecord.status == SubmitRecord.Status.OK) {
         break;
       }
     }
-    Collections.reverse(out);
-
-    // This transformation is required to adapt Prolog's behavior to the way Gerrit handles
-    // SubmitRecords, as defined in the SubmitRecord#allRecordsOK method.
-    // When several rules are defined in Prolog, they are all matched to a SubmitRecord. We want
-    // the change to be submittable when at least one result is OK.
-    if (foundOk) {
-      for (SubmitRecord record : out) {
-        record.status = SubmitRecord.Status.OK;
-      }
-    }
-
-    return out;
+    Collections.reverse(resultSubmitRecord.labels);
+    return resultSubmitRecord;
   }
 
   @VisibleForTesting
@@ -306,7 +288,7 @@
     return VALID_LABEL_MATCHER.retainFrom(name);
   }
 
-  private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
+  private SubmitRecord invalidResult(Term rule, Term record, String reason) {
     return ruleError(
         String.format(
             "Submit rule %s for change %s of %s output invalid result: %s%s",
@@ -317,15 +299,15 @@
             (reason == null ? "" : ". Reason: " + reason)));
   }
 
-  private List<SubmitRecord> invalidResult(Term rule, Term record) {
+  private SubmitRecord invalidResult(Term rule, Term record) {
     return invalidResult(rule, record, null);
   }
 
-  private List<SubmitRecord> ruleError(String err) {
+  private SubmitRecord ruleError(String err) {
     return ruleError(err, null);
   }
 
-  private List<SubmitRecord> ruleError(String err, Exception e) {
+  private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
       logger.atSevere().withCause(e).log(err);
       return defaultRuleError();
@@ -465,22 +447,22 @@
     PrologEnvironment env;
     try {
       PrologMachineCopy pmc;
-      if (opts.rule() == null) {
+      if (opts.rule().isPresent()) {
+        pmc = rulesCache.loadMachine("stdin", new StringReader(opts.rule().get()));
+      } else {
         pmc =
             rulesCache.loadMachine(
                 projectState.getNameKey(), projectState.getConfig().getRulesId());
-      } else {
-        pmc = rulesCache.loadMachine("stdin", new StringReader(opts.rule()));
       }
       env = envFactory.create(pmc);
     } catch (CompileException err) {
       String msg;
-      if (opts.rule() == null) {
+      if (opts.rule().isPresent()) {
+        msg = err.getMessage();
+      } else {
         msg =
             String.format(
                 "Cannot load rules.pl for %s: %s", projectState.getName(), err.getMessage());
-      } else {
-        msg = err.getMessage();
       }
       throw new RuleEvalException(msg, err);
     }
@@ -540,7 +522,7 @@
     if (status instanceof StructureTerm && status.arity() == 1) {
       Term who = status.arg(0);
       if (isUser(who)) {
-        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
+        label.appliedBy = Account.id(((IntegerTerm) who.arg(0)).intValue());
       } else {
         throw new UserTermExpected(label);
       }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index d4e90f9..0cd21a4 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -19,8 +19,8 @@
 import com.google.common.base.Joiner;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 30d329d..1e08a24 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -16,12 +16,12 @@
 
 import static com.google.gerrit.server.rules.StoredValue.create;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -42,7 +42,6 @@
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 
@@ -97,9 +96,8 @@
           PatchListCache plCache = env.getArgs().getPatchListCache();
           Change change = getChange(engine);
           Project.NameKey project = change.getProject();
-          ObjectId b = ObjectId.fromString(ps.getRevision().get());
           Whitespace ws = Whitespace.IGNORE_NONE;
-          PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
+          PatchListKey plKey = PatchListKey.againstDefaultBase(ps.commitId(), ws);
           PatchList patchList;
           try {
             patchList = plCache.get(plKey, project);
diff --git a/java/com/google/gerrit/server/rules/SubmitRule.java b/java/com/google/gerrit/server/rules/SubmitRule.java
index 2a68683..b221117 100644
--- a/java/com/google/gerrit/server/rules/SubmitRule.java
+++ b/java/com/google/gerrit/server/rules/SubmitRule.java
@@ -15,15 +15,14 @@
 
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.util.Collection;
+import java.util.Optional;
 
 /**
  * Allows plugins to decide whether a change is ready to be submitted or not.
  *
- * <p>For a given {@link ChangeData}, each plugin is called and returns a {@link Collection} of
- * {@link SubmitRecord}. This collection can be empty, or contain one or several values.
+ * <p>For a given {@link ChangeData}, each plugin is called and returns a {@link Optional} of {@link
+ * SubmitRecord}.
  *
  * <p>A Change can only be submitted if all the plugins give their consent.
  *
@@ -39,6 +38,9 @@
  */
 @ExtensionPoint
 public interface SubmitRule {
-  /** Returns a {@link Collection} of {@link SubmitRecord} status for the change. */
-  Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options);
+  /**
+   * Returns a {@link Optional} of {@link SubmitRecord} status for the change. {@code
+   * Optional#empty()} if the SubmitRule was a no-op.
+   */
+  Optional<SubmitRecord> evaluate(ChangeData changeData);
 }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9446b7c..c15efba 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
+import static com.google.gerrit.entities.RefNames.REFS_SEQUENCES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -30,8 +30,8 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 7231b18..6e11a5d 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
 
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index d2f5ef1..4904028 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index ee99c67..cee0862 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -9,23 +9,23 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-archive",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
     ],
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 0612bb9..49dcc46 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -21,19 +21,22 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import java.nio.file.Path;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
@@ -212,14 +215,22 @@
 
   @Override
   public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Mark file as reviewed",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .filePath(path)
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
                     + "(account_id, change_id, patch_set_id, file_name) VALUES "
                     + "(?, ?, ?, ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
@@ -239,7 +250,15 @@
       return;
     }
 
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Mark files as reviewed",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .resourceCount(paths.size())
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
@@ -247,7 +266,7 @@
                     + "(?, ?, ?, ?)")) {
       for (String path : paths) {
         stmt.setInt(1, accountId.get());
-        stmt.setInt(2, psId.getParentKey().get());
+        stmt.setInt(2, psId.changeId().get());
         stmt.setInt(3, psId.get());
         stmt.setString(4, path);
         stmt.addBatch();
@@ -264,14 +283,22 @@
 
   @Override
   public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear reviewed flag",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .filePath(path)
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
                     + "WHERE account_id = ? AND change_id = ? AND "
                     + "patch_set_id = ? AND file_name = ?")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
@@ -282,12 +309,16 @@
 
   @Override
   public void clearReviewed(PatchSet.Id psId) {
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags of patch set",
+                Metadata.builder().patchSetId(psId.get()).build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
                     + "WHERE change_id = ? AND patch_set_id = ?")) {
-      stmt.setInt(1, psId.getParentKey().get());
+      stmt.setInt(1, psId.changeId().get());
       stmt.setInt(2, psId.get());
       stmt.executeUpdate();
     } catch (SQLException e) {
@@ -297,7 +328,11 @@
 
   @Override
   public void clearReviewed(Change.Id changeId) {
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags of change",
+                Metadata.builder().changeId(changeId.get()).build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement("DELETE FROM account_patch_reviews WHERE change_id = ?")) {
       stmt.setInt(1, changeId.get());
@@ -309,7 +344,11 @@
 
   @Override
   public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Find reviewed flags",
+                Metadata.builder().patchSetId(psId.get()).accountId(accountId.get()).build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "SELECT patch_set_id, file_name FROM account_patch_reviews APR1 "
@@ -319,11 +358,11 @@
                     + "AND APR1.change_id = APR2.change_id "
                     + "AND patch_set_id <= ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       try (ResultSet rs = stmt.executeQuery()) {
         if (rs.next()) {
-          PatchSet.Id id = new PatchSet.Id(psId.getParentKey(), rs.getInt("patch_set_id"));
+          PatchSet.Id id = PatchSet.id(psId.changeId(), rs.getInt("patch_set_id"));
           ImmutableSet.Builder<String> builder = ImmutableSet.builder();
           do {
             builder.add(rs.getString("file_name"));
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
index df95ff7..0e22af9 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -20,8 +20,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java
index 7ff0ea6..803872c 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+import static com.google.gerrit.entities.RefNames.REFS_VERSION;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 9e12807..5e7dbf0 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -20,8 +20,8 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 0a5823a..26f1990 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -17,10 +17,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -218,8 +218,8 @@
   private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
     int next = seqs.nextGroupId();
     return InternalGroupCreation.builder()
-        .setNameKey(new AccountGroup.NameKey(groupReference.getName()))
-        .setId(new AccountGroup.Id(next))
+        .setNameKey(AccountGroup.nameKey(groupReference.getName()))
+        .setId(AccountGroup.id(next))
         .setGroupUUID(groupReference.getUUID())
         .build();
   }
diff --git a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
index 297fdcd..468c26b 100644
--- a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
+++ b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.schema;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 63837b2..a6424b9 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.schema.testing;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
@@ -151,8 +152,8 @@
 
     Set<String> subsections1 = config1.getSubsections(section);
     Set<String> subsections2 = config2.getSubsections(section);
-    assertThat(subsections1)
-        .named("section \"%s\"", section)
+    assertWithMessage("section \"%s\"", section)
+        .that(subsections1)
         .containsExactlyElementsIn(subsections2);
 
     subsections1.forEach(s -> assertSubsectionEquivalent(config1, config2, section, s));
@@ -163,12 +164,12 @@
     Set<String> subsectionNames1 = config1.getNames(section, subsection);
     Set<String> subsectionNames2 = config2.getNames(section, subsection);
     String name = String.format("subsection \"%s\" of section \"%s\"", subsection, section);
-    assertThat(subsectionNames1).named(name).containsExactlyElementsIn(subsectionNames2);
+    assertWithMessage(name).that(subsectionNames1).containsExactlyElementsIn(subsectionNames2);
 
     subsectionNames1.forEach(
         n ->
-            assertThat(config1.getStringList(section, subsection, n))
-                .named(name)
+            assertWithMessage(name)
+                .that(config1.getStringList(section, subsection, n))
                 .asList()
                 .containsExactlyElementsIn(config2.getStringList(section, subsection, n)));
   }
diff --git a/java/com/google/gerrit/server/schema/testing/BUILD b/java/com/google/gerrit/server/schema/testing/BUILD
index d641c47..77bb777 100644
--- a/java/com/google/gerrit/server/schema/testing/BUILD
+++ b/java/com/google/gerrit/server/schema/testing/BUILD
@@ -7,10 +7,10 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/securestore/testing/BUILD b/java/com/google/gerrit/server/securestore/testing/BUILD
index c2582b9..9afc44a 100644
--- a/java/com/google/gerrit/server/securestore/testing/BUILD
+++ b/java/com/google/gerrit/server/securestore/testing/BUILD
@@ -8,6 +8,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/server",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
index 74bb50c..3ba446c 100644
--- a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
+++ b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.ssh;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
diff --git a/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
index beaf1ba..2241d86 100644
--- a/java/com/google/gerrit/server/ssh/SshKeyCreator.java
+++ b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.ssh;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 
 public interface SshKeyCreator {
diff --git a/java/com/google/gerrit/server/submit/ChangeSet.java b/java/com/google/gerrit/server/submit/ChangeSet.java
index e721be41ab..65e0b48 100644
--- a/java/com/google/gerrit/server/submit/ChangeSet.java
+++ b/java/com/google/gerrit/server/submit/ChangeSet.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
 import java.util.LinkedHashMap;
@@ -76,8 +76,8 @@
     return changeData;
   }
 
-  public ListMultimap<Branch.NameKey, ChangeData> changesByBranch() {
-    ListMultimap<Branch.NameKey, ChangeData> ret =
+  public ListMultimap<BranchNameKey, ChangeData> changesByBranch() {
+    ListMultimap<BranchNameKey, ChangeData> ret =
         MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : changeData.values()) {
       ret.put(cd.change().getDest(), cd);
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 8ff3cc5..8b7b2cd 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -19,11 +19,11 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.MergeTip;
@@ -162,7 +162,7 @@
               ctx.getUpdate(psId),
               psId,
               newCommit,
-              prevPs != null ? prevPs.getGroups() : ImmutableList.of(),
+              prevPs != null ? prevPs.groups() : ImmutableList.of(),
               null,
               null);
       ctx.getChange().setCurrentPatchSet(patchSetInfo);
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index 12172dd..bf8b840 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -17,7 +17,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -49,7 +49,8 @@
           + "Please rebase the change locally and upload the rebased commit for review."),
 
   SKIPPED_IDENTICAL_TREE(
-      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
+      "Marking change merged without cherry-picking to branch, as the resulting commit would be"
+          + " empty."),
 
   MISSING_DEPENDENCY("Depends on change that was not submitted."),
 
@@ -102,24 +103,22 @@
           commit, otherCommit, caller != null ? caller.getLoggableName() : "<user-not-available>");
     } else if (changes.size() == 1) {
       ChangeData cd = changes.get(0);
-      if (cd.currentPatchSet().getRevision().get().equals(otherCommit)) {
+      if (cd.currentPatchSet().commitId().name().equals(otherCommit)) {
         return String.format(
             "Commit %s depends on commit %s of change %d which cannot be merged.",
             commit, otherCommit, cd.getId().get());
       }
       Optional<PatchSet> patchSet =
-          cd.patchSets().stream()
-              .filter(ps -> ps.getRevision().get().equals(otherCommit))
-              .findAny();
+          cd.patchSets().stream().filter(ps -> ps.commitId().name().equals(otherCommit)).findAny();
       if (patchSet.isPresent()) {
         return String.format(
             "Commit %s depends on commit %s, which is outdated patch set %d of change %d."
                 + " The latest patch set is %d.",
             commit,
             otherCommit,
-            patchSet.get().getId().get(),
+            patchSet.get().id().get(),
             cd.getId().get(),
-            cd.currentPatchSet().getId().get());
+            cd.currentPatchSet().id().get());
       }
       // should not happen, fall-back to default message
       return String.format(
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index a1f56eb..c94d49e 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -16,9 +16,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index 08f5abb..c83e113 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
 
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index d49f53f..f8f6bc4 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -16,9 +16,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
@@ -45,7 +45,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    GitModules create(Branch.NameKey project, MergeOpRepoManager m);
+    GitModules create(BranchNameKey project, MergeOpRepoManager m);
   }
 
   private static final String GIT_MODULES = ".gitmodules";
@@ -55,16 +55,16 @@
   @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      @Assisted Branch.NameKey branch,
+      @Assisted BranchNameKey branch,
       @Assisted MergeOpRepoManager orm)
       throws IOException {
-    Project.NameKey project = branch.getParentKey();
+    Project.NameKey project = branch.project();
     logger.atFine().log("Loading .gitmodules of %s for project %s", branch, project);
     try {
       OpenRepo or = orm.getRepo(project);
-      ObjectId id = or.repo.resolve(branch.get());
+      ObjectId id = or.repo.resolve(branch.branch());
       if (id == null) {
-        throw new IOException("Cannot open branch " + branch.get());
+        throw new IOException("Cannot open branch " + branch.branch());
       }
       RevCommit commit = or.rw.parseCommit(id);
 
@@ -80,7 +80,7 @@
         config = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
       } catch (ConfigInvalidException e) {
         throw new IOException(
-            "Could not read .gitmodules of super project: " + branch.getParentKey(), e);
+            "Could not read .gitmodules of super project: " + branch.project(), e);
       }
       subscriptions =
           new SubmoduleSectionParser(config, canonicalWebUrl, branch).parseAllSections();
@@ -89,7 +89,7 @@
     }
   }
 
-  Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
+  Collection<SubmoduleSubscription> subscribedTo(BranchNameKey src) {
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 1219124..ec6b35a 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -23,12 +23,12 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -53,7 +53,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -75,12 +74,12 @@
 
   @AutoValue
   abstract static class QueryKey {
-    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
+    private static QueryKey create(BranchNameKey branch, Iterable<String> hashes) {
       return new AutoValue_LocalMergeSuperSetComputation_QueryKey(
           branch, ImmutableSet.copyOf(hashes));
     }
 
-    abstract Branch.NameKey branch();
+    abstract BranchNameKey branch();
 
     abstract ImmutableSet<String> hashes();
   }
@@ -88,7 +87,7 @@
   private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
-  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+  private final Map<BranchNameKey, Optional<RevCommit>> heads;
   private final ProjectCache projectCache;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
 
@@ -115,10 +114,10 @@
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
-    ImmutableListMultimap<Branch.NameKey, ChangeData> bc =
+    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
         byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (Branch.NameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.getParentKey());
+    for (BranchNameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(orm, b.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
       for (ChangeData cd : bc.get(b)) {
@@ -135,8 +134,7 @@
         }
 
         // Get the underlying git commit object
-        String objIdStr = cd.currentPatchSet().getRevision().get();
-        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
 
         // Always include the input, even if merged. This allows
         // SubmitStrategyOp to correct the situation later, assuming it gets
@@ -161,9 +159,9 @@
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
-  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
+  private static ImmutableListMultimap<BranchNameKey, ChangeData> byBranch(
       Iterable<ChangeData> changes) {
-    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+    ImmutableListMultimap.Builder<BranchNameKey, ChangeData> builder =
         ImmutableListMultimap.builder();
     for (ChangeData cd : changes) {
       builder.put(cd.change().getDest(), cd);
@@ -213,7 +211,7 @@
 
   private ChangeSet byCommitsOnBranchNotMerged(
       OpenRepo or,
-      Branch.NameKey branch,
+      BranchNameKey branch,
       Set<String> visibleHashes,
       Set<String> nonVisibleHashes,
       CurrentUser user)
@@ -236,7 +234,7 @@
   }
 
   private ImmutableList<ChangeData> byCommitsOnBranchNotMerged(
-      OpenRepo or, Branch.NameKey branch, Set<String> hashes) throws IOException {
+      OpenRepo or, BranchNameKey branch, Set<String> hashes) throws IOException {
     if (hashes.isEmpty()) {
       return ImmutableList.of();
     }
@@ -252,7 +250,7 @@
   }
 
   private Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
     or.rw.reset();
@@ -276,10 +274,10 @@
     return destHashes;
   }
 
-  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
+  private void markHeadUninteresting(OpenRepo or, BranchNameKey b) throws IOException {
     Optional<RevCommit> head = heads.get(b);
     if (head == null) {
-      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      Ref ref = or.repo.getRefDatabase().exactRef(b.branch());
       head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
       heads.put(b, head);
     }
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java
index 9806bdf..4555a32 100644
--- a/java/com/google/gerrit/server/submit/MergeOneOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 63d60a6..adb75a4 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,6 +36,11 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -48,11 +53,6 @@
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -91,6 +91,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -119,7 +120,7 @@
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
-    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
+    private final ImmutableSetMultimap<BranchNameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
     private final ListMultimap<Change.Id, String> problems;
     private final boolean allowClosed;
@@ -128,7 +129,7 @@
       checkArgument(
           !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
       changes = cs.changesById();
-      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb = ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<BranchNameKey, Change.Id> bb = ImmutableSetMultimap.builder();
       for (ChangeData cd : cs.changes()) {
         bb.put(cd.change().getDest(), cd.getId());
       }
@@ -142,7 +143,7 @@
       return changes.keySet();
     }
 
-    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
+    public ImmutableSet<Change.Id> getChangeIds(BranchNameKey branch) {
       return byBranch.get(branch);
     }
 
@@ -233,6 +234,9 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
 
+  // Changes that were updated by this MergeOp.
+  private final Map<Change.Id, Change> updatedChanges;
+
   private Timestamp ts;
   private RequestId submissionId;
   private IdentifiedUser caller;
@@ -244,6 +248,7 @@
   private Set<Project.NameKey> allProjects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
+  private String traceId;
 
   @Inject
   MergeOp(
@@ -273,6 +278,7 @@
     this.retryHelper = retryHelper;
     this.topicMetrics = topicMetrics;
     this.changeDataFactory = changeDataFactory;
+    this.updatedChanges = new HashMap<>();
   }
 
   @Override
@@ -296,7 +302,7 @@
       throw new IllegalStateException(
           String.format(
               "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
-              cd.getId(), patchSet.getId(), cd.change().getProject().get()));
+              cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
     for (SubmitRecord record : results) {
@@ -318,7 +324,7 @@
           throw new IllegalStateException(
               String.format(
                   "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
+                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -428,8 +434,9 @@
    * @throws RestApiException if an error occurred.
    * @throws PermissionBackendException if permissions can't be checked
    * @throws IOException an error occurred reading from NoteDb.
+   * @return the merged change
    */
-  public void merge(
+  public Change merge(
       Change change,
       IdentifiedUser caller,
       boolean checkSubmitRules,
@@ -511,11 +518,22 @@
                     retryHelper
                         .getDefaultTimeout(ActionType.CHANGE_UPDATE)
                         .multipliedBy(cs.projects().size()))
+                .caller(getClass())
+                .retryWithTrace(t -> !(t instanceof RestApiException))
+                .onAutoTrace(traceId -> this.traceId = traceId)
                 .build());
 
         if (projects > 1) {
           topicMetrics.topicSubmissionsCompleted.increment();
         }
+
+        // It's expected that callers invoke this method only for open changes and that the provided
+        // change either gets updated to merged or that this method fails with an exception. For
+        // safety, fall-back to return the provided change if there was no update for this change
+        // (e.g. caller provided a change that was already merged).
+        return updatedChanges.containsKey(change.getId())
+            ? updatedChanges.get(change.getId())
+            : change;
       } catch (IOException e) {
         // Anything before the merge attempt is an error
         throw new StorageException(e);
@@ -523,6 +541,10 @@
     }
   }
 
+  public Optional<String> getTraceId() {
+    return Optional.ofNullable(traceId);
+  }
+
   private void openRepoManager() {
     if (orm != null) {
       orm.close();
@@ -573,18 +595,18 @@
       throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
-    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
+    Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb;
+    ListMultimap<BranchNameKey, ChangeData> cbb;
     try {
       cbb = cs.changesByBranch();
     } catch (StorageException e) {
       throw new IntegrationException("Error reading changes to submit", e);
     }
-    Set<Branch.NameKey> branches = cbb.keySet();
+    Set<BranchNameKey> branches = cbb.keySet();
 
-    for (Branch.NameKey branch : branches) {
-      OpenRepo or = openRepo(branch.getParentKey());
+    for (BranchNameKey branch : branches) {
+      OpenRepo or = openRepo(branch.project());
       if (or != null) {
         toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
       }
@@ -597,10 +619,17 @@
       SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
       List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
       this.allProjects = submoduleOp.getProjectsInOrder();
-      BatchUpdate.execute(
-          orm.batchUpdates(allProjects),
-          new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          dryrun);
+      try {
+        BatchUpdate.execute(
+            orm.batchUpdates(allProjects),
+            new SubmitStrategyListener(submitInput, strategies, commitStatus),
+            dryrun);
+      } finally {
+        // If the BatchUpdate fails it can be that merging some of the changes was actually
+        // successful. This is why we must to collect the updated changes also when an exception was
+        // thrown.
+        strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+      }
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
     } catch (IOException | SubmoduleException e) {
@@ -641,14 +670,14 @@
   }
 
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
       throws IntegrationException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
         toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
-    for (Branch.NameKey branch : allBranches) {
-      OpenRepo or = orm.getRepo(branch.getParentKey());
+    for (BranchNameKey branch : allBranches) {
+      OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
         logger.atFine().log("adding ops for branch batch %s", submitting);
@@ -769,55 +798,47 @@
       }
 
       PatchSet ps;
-      Branch.NameKey destBranch = chg.getDest();
+      BranchNameKey destBranch = chg.getDest();
       try {
         ps = cd.currentPatchSet();
       } catch (StorageException e) {
         commitStatus.logProblem(changeId, e);
         continue;
       }
-      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
-        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
+      if (ps == null) {
+        commitStatus.logProblem(changeId, "Missing patch set on change");
         continue;
       }
 
-      String idstr = ps.getRevision().get();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(idstr);
-      } catch (IllegalArgumentException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-
-      if (!revisions.containsEntry(id, ps.getId())) {
-        if (revisions.containsValue(ps.getId())) {
+      ObjectId id = ps.commitId();
+      if (!revisions.containsEntry(id, ps.id())) {
+        if (revisions.containsValue(ps.id())) {
           // TODO This is actually an error, the patch set ref exists but points to a revision that
           // is different from the revision that we have stored for the patch set in the change
           // meta data.
           commitStatus.logProblem(
               changeId,
               "Revision "
-                  + idstr
+                  + id.name()
                   + " of patch set "
-                  + ps.getPatchSetId()
+                  + ps.number()
                   + " does not match the revision of the patch set ref "
-                  + ps.getId().toRefName());
+                  + ps.id().toRefName());
           continue;
         }
 
         // The patch set ref is not found but we want to merge the change. We can't safely do that
-        // if the patch set ref is missing. In a multi-master setup this can indicate a replication
-        // lag (e.g. the change meta data was already replicated, but the replication of the patch
-        // set ref is still pending).
+        // if the patch set ref is missing. In a cluster setups with multiple primary nodes this can
+        // indicate a replication lag (e.g. the change meta data was already replicated, but the
+        // replication of the patch set ref is still pending).
         commitStatus.logProblem(
             changeId,
             "Patch set ref "
-                + ps.getId().toRefName()
+                + ps.id().toRefName()
                 + " not found. Expected patch set ref of "
-                + ps.getPatchSetId()
+                + ps.number()
                 + " to point to revision "
-                + idstr);
+                + id.name());
         continue;
       }
 
@@ -830,13 +851,12 @@
       }
 
       commit.setNotes(notes);
-      commit.setPatchsetId(ps.getId());
+      commit.setPatchsetId(ps.id());
       commitStatus.put(commit);
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
-        mergeValidators.validatePreMerge(
-            or.repo, commit, or.project, destBranch, ps.getId(), caller);
+        mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
       } catch (MergeValidationException mve) {
         commitStatus.problem(changeId, mve.getMessage());
         continue;
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 764aca8..c2577e7 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -18,9 +18,9 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Maps;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -67,7 +67,7 @@
     BatchUpdate update;
 
     private final ObjectReader reader;
-    private final Map<Branch.NameKey, OpenBranch> branches;
+    private final Map<BranchNameKey, OpenBranch> branches;
 
     private OpenRepo(Repository repo, ProjectState project) {
       this.repo = repo;
@@ -84,7 +84,7 @@
       branches = Maps.newHashMapWithExpectedSize(1);
     }
 
-    OpenBranch getBranch(Branch.NameKey branch) throws IntegrationException {
+    OpenBranch getBranch(BranchNameKey branch) throws IntegrationException {
       OpenBranch ob = branches.get(branch);
       if (ob == null) {
         ob = new OpenBranch(this, branch);
@@ -134,13 +134,13 @@
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
-    OpenBranch(OpenRepo or, Branch.NameKey name) throws IntegrationException {
+    OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
       try {
-        update = or.repo.updateRef(name.get());
+        update = or.repo.updateRef(name.branch());
         if (update.getOldObjectId() != null) {
           oldTip = or.rw.parseCommit(update.getOldObjectId());
-        } else if (Objects.equals(or.repo.getFullBranch(), name.get())
-            || Objects.equals(RefNames.REFS_CONFIG, name.get())) {
+        } else if (Objects.equals(or.repo.getFullBranch(), name.branch())
+            || Objects.equals(RefNames.REFS_CONFIG, name.branch())) {
           oldTip = null;
           update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index d729833..bcebc7f 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -19,9 +19,9 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 829ee9c..4775768 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -108,7 +108,7 @@
     return sorted;
   }
 
-  private boolean isAlreadyMerged(CodeReviewCommit commit, Branch.NameKey dest) throws IOException {
+  private boolean isAlreadyMerged(CodeReviewCommit commit, BranchNameKey dest) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
       mirw.markStart(commit);
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index b2ad4e2..65e18ad 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -19,12 +19,12 @@
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -235,7 +235,7 @@
                 ctx.getUpdate(newPatchSetId),
                 newPatchSetId,
                 newCommit,
-                prevPs != null ? prevPs.getGroups() : ImmutableList.of(),
+                prevPs != null ? prevPs.groups() : ImmutableList.of(),
                 null,
                 null);
       }
@@ -310,7 +310,6 @@
       throws IntegrationException {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
-    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
+    return args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index 391d956..ff1a1f0 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -20,8 +20,8 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -112,7 +112,7 @@
       SubmitType submitType,
       Repository repo,
       CodeReviewRevWalk rw,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       ObjectId tip,
       ObjectId toMerge,
       Set<RevCommit> alreadyAccepted)
@@ -155,10 +155,10 @@
     }
   }
 
-  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
-    ProjectState p = projectCache.get(branch.getParentKey());
+  private ProjectState getProject(BranchNameKey branch) throws NoSuchProjectException {
+    ProjectState p = projectCache.get(branch.project());
     if (p == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     return p;
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index dc221f8..4c68e1b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -55,7 +57,9 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -82,7 +86,7 @@
     interface Factory {
       Arguments create(
           SubmitType submitType,
-          Branch.NameKey destBranch,
+          BranchNameKey destBranch,
           CommitStatus commitStatus,
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
@@ -114,7 +118,7 @@
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
 
-    final Branch.NameKey destBranch;
+    final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
@@ -152,7 +156,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
-        @Assisted Branch.NameKey destBranch,
+        @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
@@ -197,8 +201,8 @@
 
       this.project =
           requireNonNull(
-              projectCache.get(destBranch.getParentKey()),
-              () -> String.format("project not found: %s", destBranch.getParentKey()));
+              projectCache.get(destBranch.project()),
+              () -> String.format("project not found: %s", destBranch.project()));
       this.mergeSorter =
           new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
       this.rebaseSorter =
@@ -217,8 +221,24 @@
 
   final Arguments args;
 
+  private final Set<SubmitStrategyOp> submitStrategyOps;
+
   SubmitStrategy(Arguments args) {
     this.args = requireNonNull(args);
+    this.submitStrategyOps = new HashSet<>();
+  }
+
+  /**
+   * Returns the updated changed after this submit strategy has been executed.
+   *
+   * @return the updated changes after this submit strategy has been executed
+   */
+  public ImmutableMap<Change.Id, Change> getUpdatedChanges() {
+    return submitStrategyOps.stream()
+        .map(SubmitStrategyOp::getUpdatedChange)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(toImmutableMap(c -> c.getId(), c -> c));
   }
 
   /**
@@ -249,8 +269,10 @@
     for (CodeReviewCommit c : difference) {
       Change.Id id = c.change().getId();
       bu.addOp(id, args.setPrivateOpFactory.create(false, null));
-      bu.addOp(id, new ImplicitIntegrateOp(args, c));
+      ImplicitIntegrateOp implicitIntegrateOp = new ImplicitIntegrateOp(args, c);
+      bu.addOp(id, implicitIntegrateOp);
       maybeAddTestHelperOp(bu, id);
+      this.submitStrategyOps.add(implicitIntegrateOp);
     }
 
     // Then ops for explicitly merged changes
@@ -258,6 +280,7 @@
       bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
       bu.addOp(op.getId(), op);
       maybeAddTestHelperOp(bu, op.getId());
+      this.submitStrategyOps.add(op);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 30326f7..cba572bc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -48,7 +48,7 @@
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       IdentifiedUser caller,
       MergeTip mergeTip,
       CommitStatus commitStatus,
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index 782cd7b..f8bcfc1 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -17,9 +17,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
@@ -129,7 +129,7 @@
 
         case ALREADY_MERGED:
           // Already an ancestor of tip.
-          alreadyMerged.add(commit.getPatchsetId().getParentKey());
+          alreadyMerged.add(commit.getPatchsetId().changeId());
           break;
 
         case PATH_CONFLICT:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 14522a2..79f062d 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -20,19 +20,18 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Function;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -89,12 +88,12 @@
     return toMerge;
   }
 
-  protected final Branch.NameKey getDest() {
+  protected final BranchNameKey getDest() {
     return toMerge.change().getDest();
   }
 
   protected final Project.NameKey getProject() {
-    return getDest().getParentKey();
+    return getDest().project();
   }
 
   @Override
@@ -132,14 +131,15 @@
     // Needed by postUpdate, at which point mergeTip will have advanced further,
     // so it's easier to just snapshot the command.
     command =
-        new ReceiveCommand(firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().get());
+        new ReceiveCommand(
+            firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
     ctx.addRefUpdate(command);
     args.submoduleOp.addBranchTip(getDest(), tipAfter);
   }
 
   private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
       throws IntegrationException {
-    String refName = getDest().get();
+    String refName = getDest().branch();
     if (RefNames.REFS_CONFIG.equals(refName)) {
       logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
@@ -251,7 +251,7 @@
                 args.psUtil.get(ctx.getNotes(), oldPsId),
                 () -> String.format("missing old patch set %s", oldPsId));
       } else {
-        PatchSet.Id n = newPatchSet.getId();
+        PatchSet.Id n = newPatchSet.id();
         checkState(
             !n.equals(oldPsId) && n.equals(newPsId),
             "current patch was %s and is now %s, but updateChangeImpl returned"
@@ -294,6 +294,16 @@
     return true;
   }
 
+  /**
+   * Returns the updated change after this op has been executed.
+   *
+   * @return the updated change after this op has been executed, {@link Optional#empty()} if the op
+   *     was not executed yet, or if the execution has failed
+   */
+  public Optional<Change> getUpdatedChange() {
+    return Optional.ofNullable(updatedChange);
+  }
+
   private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx) throws IOException {
     PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
     logger.atFine().log("Fixing up already-merged patch set %s", psId);
@@ -311,7 +321,7 @@
     // a patch set ref. Fix up the database. Note that this uses the current
     // user as the uploader, which is as good a guess as any.
     List<String> groups =
-        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
+        prevPs != null ? prevPs.groups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
     return args.psUtil.insert(
         ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
@@ -333,7 +343,7 @@
     // approvals as well.
     if (!newPsId.equals(oldPsId)) {
       saveApprovals(normalized, newPsUpdate, true);
-      submitter = convertPatchSet(newPsId).apply(submitter);
+      submitter = submitter.copyWithPatchSet(newPsId);
     }
   }
 
@@ -344,12 +354,13 @@
     for (PatchSetApproval psa :
         args.approvalsUtil.byPatchSet(
             ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-      byKey.put(psa.getKey(), psa);
+      byKey.put(psa.key(), psa);
     }
 
     submitter =
-        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    byKey.put(submitter.getKey(), submitter);
+        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen())
+            .build();
+    byKey.put(submitter.key(), submitter);
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -358,7 +369,7 @@
     // permissions get modified in the future, historical records stay accurate.
     LabelNormalizer.Result normalized =
         args.labelNormalizer.normalize(ctx.getNotes(), byKey.values());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
+    update.putApproval(submitter.label(), submitter.value());
     saveApprovals(normalized, update, false);
     return normalized;
   }
@@ -366,10 +377,10 @@
   private void saveApprovals(
       LabelNormalizer.Result normalized, ChangeUpdate update, boolean includeUnchanged) {
     for (PatchSetApproval psa : normalized.updated()) {
-      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+      update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
     }
     for (PatchSetApproval psa : normalized.deleted()) {
-      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+      update.removeApprovalFor(psa.accountId(), psa.label());
     }
 
     // TODO(dborowitz): Don't use a label in NoteDb; just check when status
@@ -377,27 +388,17 @@
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
         logger.atFine().log("Adding submit label %s", psa);
-        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+        update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
       }
     }
   }
 
-  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
-      final PatchSet.Id psId) {
-    return psa -> {
-      if (psa.getPatchSetId().equals(psId)) {
-        return psa;
-      }
-      return new PatchSetApproval(psId, psa);
-    };
-  }
-
   private String getByAccountName() {
     requireNonNull(submitter, "getByAccountName called before submitter populated");
     Optional<Account> account =
-        args.accountCache.get(submitter.getAccountId()).map(AccountState::getAccount);
-    if (account.isPresent() && account.get().getFullName() != null) {
-      return " by " + account.get().getFullName();
+        args.accountCache.get(submitter.accountId()).map(AccountState::account);
+    if (account.isPresent() && account.get().fullName() != null) {
+      return " by " + account.get().fullName();
     }
     return "";
   }
@@ -483,7 +484,7 @@
           getProject(), command.getRefName(), command.getOldId(), command.getNewId());
       // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
       // per project even if multiple changes to refs/meta/config are submitted.
-      if (RefNames.REFS_CONFIG.equals(getDest().get())) {
+      if (RefNames.REFS_CONFIG.equals(getDest().branch())) {
         args.projectCache.evict(getProject());
         ProjectState p = args.projectCache.get(getProject());
         try (Repository git = args.repoManager.openRepository(getProject())) {
@@ -498,7 +499,7 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.getAccountId(), ctx.getNotify(getId()))
+          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -507,7 +508,7 @@
       args.changeMerged.fire(
           updatedChange,
           mergedPatchSet,
-          args.accountCache.get(submitter.getAccountId()).orElse(null),
+          args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
           ctx.getWhen());
     }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index afcf9c5..8ab99dd 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -23,11 +23,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
@@ -79,9 +79,9 @@
 
   /** Only used for branches without code review changes */
   public class GitlinkOp implements RepoOnlyOp {
-    private final Branch.NameKey branch;
+    private final BranchNameKey branch;
 
-    GitlinkOp(Branch.NameKey branch) {
+    GitlinkOp(BranchNameKey branch) {
       this.branch = branch;
     }
 
@@ -89,7 +89,7 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(c.getParent(0), c, branch.get());
+        ctx.addRefUpdate(c.getParent(0), c, branch.branch());
         addBranchTip(branch, c);
       }
     }
@@ -114,7 +114,7 @@
       this.projectCache = projectCache;
     }
 
-    public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
+    public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleException {
       return new SubmoduleOp(
           gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
@@ -129,41 +129,41 @@
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<Branch.NameKey, GitModules> branchGitModules;
+  private final Map<BranchNameKey, GitModules> branchGitModules;
 
   /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<Branch.NameKey> updatedBranches;
+  private final ImmutableSet<BranchNameKey> updatedBranches;
 
   /**
    * Current branch tips, taking into account commits created during the submit process as well as
    * submodule updates produced by this class.
    */
-  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
+  private final Map<BranchNameKey, CodeReviewCommit> branchTips;
 
   /**
    * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
    * which are subscribed to by some superproject.
    */
-  private final Set<Branch.NameKey> affectedBranches;
+  private final Set<BranchNameKey> affectedBranches;
 
   /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<Branch.NameKey> sortedBranches;
+  private final ImmutableSet<BranchNameKey> sortedBranches;
 
   /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
+  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
 
   /**
    * Multimap of superproject name to all branch names within that superproject which have submodule
    * subscriptions.
    */
-  private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
+  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       PersonIdent myIdent,
       Config cfg,
       ProjectCache projectCache,
-      Set<Branch.NameKey> updatedBranches,
+      Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
@@ -214,15 +214,15 @@
   //
   // In addition to improving readability, this approach has the advantage of making (1) and (2)
   // testable using small tests.
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMaps() throws SubmoduleException {
+  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
       logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
     logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
-    for (Branch.NameKey updatedBranch : updatedBranches) {
+    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+    for (BranchNameKey updatedBranch : updatedBranches) {
       if (allVisited.contains(updatedBranch)) {
         continue;
       }
@@ -240,9 +240,9 @@
   }
 
   private void searchForSuperprojects(
-      Branch.NameKey current,
-      LinkedHashSet<Branch.NameKey> currentVisited,
-      LinkedHashSet<Branch.NameKey> allVisited)
+      BranchNameKey current,
+      LinkedHashSet<BranchNameKey> currentVisited,
+      LinkedHashSet<BranchNameKey> allVisited)
       throws SubmoduleException {
     logger.atFine().log("Now processing %s", current);
 
@@ -261,10 +261,10 @@
       Collection<SubmoduleSubscription> subscriptions =
           superProjectSubscriptionsForSubmoduleBranch(current);
       for (SubmoduleSubscription sub : subscriptions) {
-        Branch.NameKey superBranch = sub.getSuperProject();
+        BranchNameKey superBranch = sub.getSuperProject();
         searchForSuperprojects(superBranch, currentVisited, allVisited);
         targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.getParentKey(), superBranch);
+        branchesByProject.put(superBranch.project(), superBranch);
         affectedBranches.add(superBranch);
         affectedBranches.add(sub.getSubmodule());
       }
@@ -303,31 +303,33 @@
     return sb.toString();
   }
 
-  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
+  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
       throws IOException {
-    Collection<Branch.NameKey> ret = new HashSet<>();
+    Collection<BranchNameKey> ret = new HashSet<>();
     logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
       logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       if (r.isWildcard()) {
         // refs/heads/*[:refs/somewhere/*]
-        ret.add(new Branch.NameKey(s.getProject(), r.expandFromSource(src.get()).getDestination()));
+        ret.add(
+            BranchNameKey.create(
+                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
       } else {
         // e.g. refs/heads/master[:refs/heads/stable]
         String dest = r.getDestination();
         if (dest == null) {
           dest = r.getSource();
         }
-        ret.add(new Branch.NameKey(s.getProject(), dest));
+        ret.add(BranchNameKey.create(s.getProject(), dest));
       }
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
       logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       OpenRepo or;
@@ -344,7 +346,7 @@
         if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
           continue;
         }
-        Branch.NameKey b = new Branch.NameKey(s.getProject(), ref.getName());
+        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
         if (!ret.contains(b)) {
           ret.add(b);
         }
@@ -356,18 +358,18 @@
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      Branch.NameKey srcBranch) throws IOException {
+      BranchNameKey srcBranch) throws IOException {
     logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.getParentKey();
+    Project.NameKey srcProject = srcBranch.project();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
       logger.atFine().log("Checking subscribe section %s", s);
-      Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
-      for (Branch.NameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.getParentKey();
+      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+      for (BranchNameKey targetBranch : branches) {
+        Project.NameKey targetProject = targetBranch.project();
         try {
           OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.get());
+          ObjectId id = or.repo.resolve(targetBranch.branch());
           if (id == null) {
             logger.atFine().log("The branch %s doesn't exist.", targetBranch);
             continue;
@@ -403,7 +405,7 @@
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (Branch.NameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : branchesByProject.get(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -415,11 +417,11 @@
   }
 
   /** Create a separate gitlink commit */
-  private CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
+  private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -428,7 +430,7 @@
     if (branchTips.containsKey(subscriber)) {
       currentCommit = branchTips.get(subscriber);
     } else {
-      Ref r = or.repo.exactRef(subscriber.get());
+      Ref r = or.repo.exactRef(subscriber.branch());
       if (r == null) {
         throw new SubmoduleException(
             "The branch was probably deleted from the subscriber repository");
@@ -485,11 +487,11 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -531,7 +533,7 @@
     logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
-      subOr = orm.getRepo(s.getSubmodule().getParentKey());
+      subOr = orm.getRepo(s.getSubmodule().project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access submodule", e);
     }
@@ -544,7 +546,7 @@
             "Requested to update gitlink "
                 + s.getPath()
                 + " in "
-                + s.getSubmodule().getParentKey().get()
+                + s.getSubmodule().project().get()
                 + " but entry "
                 + "doesn't have gitlink file mode.";
         throw new SubmoduleException(errMsg);
@@ -576,7 +578,7 @@
       // superproject is still subscribed to this branch. Re-read the ref to see if anything has
       // changed since the last time the gitlink was updated, and roll that update into the same
       // commit as all other submodule updates.
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().get());
+      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
       if (ref == null) {
         ed.add(new DeletePath(s.getPath()));
         return null;
@@ -615,7 +617,7 @@
     msgbuf.append("* Update ");
     msgbuf.append(s.getPath());
     msgbuf.append(" from branch '");
-    msgbuf.append(s.getSubmodule().getShortName());
+    msgbuf.append(s.getSubmodule().shortName());
     msgbuf.append("'");
     msgbuf.append("\n  to ");
     msgbuf.append(newCommit.getName());
@@ -675,8 +677,8 @@
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (Branch.NameKey branch : updatedBranches) {
-      projects.add(branch.getParentKey());
+    for (BranchNameKey branch : updatedBranches) {
+      projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
   }
@@ -697,10 +699,10 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (Branch.NameKey branch : branchesByProject.get(project)) {
+    for (BranchNameKey branch : branchesByProject.get(project)) {
       Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
       for (SubmoduleSubscription s : subscriptions) {
-        subprojects.add(s.getSubmodule().getParentKey());
+        subprojects.add(s.getSubmodule().project());
       }
     }
 
@@ -712,8 +714,8 @@
     projects.add(project);
   }
 
-  ImmutableSet<Branch.NameKey> getBranchesInOrder() {
-    LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
+  ImmutableSet<BranchNameKey> getBranchesInOrder() {
+    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
     }
@@ -721,15 +723,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(BranchNameKey branch) {
     return targets.containsKey(branch);
   }
 
-  void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, BranchNameKey branch) {
     bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/TestHelperOp.java b/java/com/google/gerrit/server/submit/TestHelperOp.java
index bbb198a..7763e2f 100644
--- a/java/com/google/gerrit/server/submit/TestHelperOp.java
+++ b/java/com/google/gerrit/server/submit/TestHelperOp.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 0ca9f5e..ce16706 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -32,14 +32,15 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.TooManyUpdatesException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -110,7 +112,6 @@
     };
   }
 
-  // TODO(dborowitz): Make this package-private to force all callers to use RetryHelper.
   public interface Factory {
     BatchUpdate create(Project.NameKey project, CurrentUser user, Timestamp when);
   }
@@ -178,6 +179,20 @@
   }
 
   private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof TooManyUpdatesException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } else if (e instanceof CommentsRejectedException) {
+      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
+      // status code and it's isolated in monitoring.
+      throw new BadRequestException(e.getMessage(), e);
+    }
+
     Throwables.throwIfUnchecked(e);
 
     // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
@@ -185,16 +200,6 @@
     Throwables.throwIfInstanceOf(e, UpdateException.class);
     Throwables.throwIfInstanceOf(e, RestApiException.class);
 
-    // Convert other common non-REST exception types with user-visible messages to corresponding
-    // REST exception types
-    if (e instanceof InvalidChangeOperationException) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } else if (e instanceof NoSuchChangeException
-        || e instanceof NoSuchRefException
-        || e instanceof NoSuchProjectException) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    }
-
     // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
     throw new UpdateException(e);
   }
@@ -289,7 +294,7 @@
   private enum ChangeResult {
     SKIPPED,
     UPSERTED,
-    DELETED;
+    DELETED
   }
 
   private final GitRepositoryManager repoManager;
@@ -566,9 +571,7 @@
         handle.setResult(id, ChangeResult.SKIPPED);
         continue;
       }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        handle.manager.add(u);
-      }
+      ctx.updates.values().forEach(handle.manager::add);
       if (ctx.deleted) {
         logDebug("Change %s was deleted", id);
         handle.manager.deleteChange(id);
diff --git a/java/com/google/gerrit/server/update/BatchUpdateOp.java b/java/com/google/gerrit/server/update/BatchUpdateOp.java
index 87a43a3..a2c2394 100644
--- a/java/com/google/gerrit/server/update/BatchUpdateOp.java
+++ b/java/com/google/gerrit/server/update/BatchUpdateOp.java
@@ -19,7 +19,7 @@
  *
  * <p>Each operation has {@link #updateChange(ChangeContext)} called once the change is read in a
  * transaction. Ops are associated with updates via {@link
- * BatchUpdate#addOp(com.google.gerrit.reviewdb.client.Change.Id, BatchUpdateOp)}.
+ * BatchUpdate#addOp(com.google.gerrit.entities.Change.Id, BatchUpdateOp)}.
  *
  * <p>Usually, a single {@code BatchUpdateOp} instance is only associated with a single change, i.e.
  * {@code addOp} is only called once with that instance. Additionally, each method in {@code
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index 28674fc..bd6d90b 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -16,8 +16,8 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 
diff --git a/java/com/google/gerrit/server/update/CommentsRejectedException.java b/java/com/google/gerrit/server/update/CommentsRejectedException.java
new file mode 100644
index 0000000..6b0c04d
--- /dev/null
+++ b/java/com/google/gerrit/server/update/CommentsRejectedException.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+/** Thrown when comment validation rejected a comment, preventing it from being published. */
+public class CommentsRejectedException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  private final ImmutableList<CommentValidationFailure> commentValidationFailures;
+
+  public CommentsRejectedException(Collection<CommentValidationFailure> commentValidationFailures) {
+    this.commentValidationFailures = ImmutableList.copyOf(commentValidationFailures);
+  }
+
+  @Override
+  public String getMessage() {
+    return "One or more comments were rejected in validation: "
+        + commentValidationFailures.stream()
+            .map(CommentValidationFailure::getMessage)
+            .collect(Collectors.joining("; "));
+  }
+
+  /**
+   * Returns the validation failures that caused this exception. By contract this list is never
+   * empty.
+   */
+  public ImmutableList<CommentValidationFailure> getCommentValidationFailures() {
+    return commentValidationFailures;
+  }
+}
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 8704cf0..9947168 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
@@ -114,7 +114,7 @@
   /**
    * Get the account of the user performing the update.
    *
-   * <p>Convenience method for {@code getIdentifiedUser().getAccount()}.
+   * <p>Convenience method for {@code getIdentifiedUser().account()}.
    *
    * @see CurrentUser#asIdentifiedUser()
    * @return account.
diff --git a/java/com/google/gerrit/server/update/InsertChangeOp.java b/java/com/google/gerrit/server/update/InsertChangeOp.java
index 7060059..2676494 100644
--- a/java/com/google/gerrit/server/update/InsertChangeOp.java
+++ b/java/com/google/gerrit/server/update/InsertChangeOp.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.update;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import java.io.IOException;
 
 /**
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
index 73dd12f..52467a4 100644
--- a/java/com/google/gerrit/server/update/RepoView.java
+++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -18,7 +18,7 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index ae8ba53..bea3867 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -28,7 +28,6 @@
 import com.github.rholder.retry.WaitStrategy;
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Predicate;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
@@ -36,18 +35,25 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
+import java.util.function.Predicate;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -94,12 +100,24 @@
     @Nullable
     abstract Duration timeout();
 
+    abstract Optional<Class<?>> caller();
+
+    abstract Optional<Predicate<Throwable>> retryWithTrace();
+
+    abstract Optional<Consumer<String>> onAutoTrace();
+
     @AutoValue.Builder
     public abstract static class Builder {
       public abstract Builder listener(RetryListener listener);
 
       public abstract Builder timeout(Duration timeout);
 
+      public abstract Builder caller(Class<?> caller);
+
+      public abstract Builder retryWithTrace(Predicate<Throwable> exceptionPredicate);
+
+      public abstract Builder onAutoTrace(Consumer<String> traceIdConsumer);
+
       public abstract Options build();
     }
   }
@@ -107,21 +125,24 @@
   @VisibleForTesting
   @Singleton
   public static class Metrics {
-    final Histogram1<ActionType> attemptCounts;
+    final Counter1<ActionType> attemptCounts;
     final Counter1<ActionType> timeoutCount;
+    final Counter2<ActionType, String> autoRetryCount;
+    final Counter2<ActionType, String> failuresOnAutoRetryCount;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
-      Field<ActionType> view = Field.ofEnum(ActionType.class, "action_type");
+      Field<ActionType> actionTypeField =
+          Field.ofEnum(ActionType.class, "action_type", Metadata.Builder::actionType).build();
       attemptCounts =
-          metricMaker.newHistogram(
-              "action/retry_attempt_counts",
+          metricMaker.newCounter(
+              "action/retry_attempt_count",
               new Description(
-                      "Distribution of number of attempts made by RetryHelper to execute an action"
-                          + " (1 == single attempt, no retry)")
+                      "Number of retry attempts made by RetryHelper to execute an action"
+                          + " (0 == single attempt, no retry)")
                   .setCumulative()
                   .setUnit("attempts"),
-              view);
+              actionTypeField);
       timeoutCount =
           metricMaker.newCounter(
               "action/retry_timeout_count",
@@ -129,7 +150,27 @@
                       "Number of action executions of RetryHelper that ultimately timed out")
                   .setCumulative()
                   .setUnit("timeouts"),
-              view);
+              actionTypeField);
+      autoRetryCount =
+          metricMaker.newCounter(
+              "action/auto_retry_count",
+              new Description("Number of automatic retries with tracing")
+                  .setCumulative()
+                  .setUnit("retries"),
+              actionTypeField,
+              Field.ofString("operation_name", Metadata.Builder::operationName)
+                  .description("The name of the operation that was retried.")
+                  .build());
+      failuresOnAutoRetryCount =
+          metricMaker.newCounter(
+              "action/failures_on_auto_retry_count",
+              new Description("Number of failures on auto retry")
+                  .setCumulative()
+                  .setUnit("failures"),
+              actionTypeField,
+              Field.ofString("operation_name", Metadata.Builder::operationName)
+                  .description("The name of the operation that was retried.")
+                  .build());
     }
   }
 
@@ -143,13 +184,19 @@
 
   private final Metrics metrics;
   private final BatchUpdate.Factory updateFactory;
+  private final PluginSetContext<ExceptionHook> exceptionHooks;
   private final Map<ActionType, Duration> defaultTimeouts;
   private final WaitStrategy waitStrategy;
   @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
+  private final boolean retryWithTraceOnFailure;
 
   @Inject
-  RetryHelper(@GerritServerConfig Config cfg, Metrics metrics, BatchUpdate.Factory updateFactory) {
-    this(cfg, metrics, updateFactory, null);
+  RetryHelper(
+      @GerritServerConfig Config cfg,
+      Metrics metrics,
+      PluginSetContext<ExceptionHook> exceptionHooks,
+      BatchUpdate.Factory updateFactory) {
+    this(cfg, metrics, updateFactory, exceptionHooks, null);
   }
 
   @VisibleForTesting
@@ -157,9 +204,11 @@
       @GerritServerConfig Config cfg,
       Metrics metrics,
       BatchUpdate.Factory updateFactory,
+      PluginSetContext<ExceptionHook> exceptionHooks,
       @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
     this.metrics = metrics;
     this.updateFactory = updateFactory;
+    this.exceptionHooks = exceptionHooks;
 
     Duration defaultTimeout =
         Duration.ofMillis(
@@ -185,6 +234,7 @@
                 MILLISECONDS),
             WaitStrategies.randomWait(50, MILLISECONDS));
     this.overwriteDefaultRetryerStrategySetup = overwriteDefaultRetryerStrategySetup;
+    this.retryWithTraceOnFailure = cfg.getBoolean("retry", "retryWithTraceOnFailure", false);
   }
 
   public Duration getDefaultTimeout(ActionType actionType) {
@@ -255,15 +305,57 @@
       Predicate<Throwable> exceptionPredicate)
       throws Throwable {
     MetricListener listener = new MetricListener();
-    try {
-      RetryerBuilder<T> retryerBuilder = createRetryerBuilder(actionType, opts, exceptionPredicate);
+    try (TraceContext traceContext = TraceContext.open()) {
+      RetryerBuilder<T> retryerBuilder =
+          createRetryerBuilder(
+              actionType,
+              opts,
+              t -> {
+                // exceptionPredicate checks for temporary errors for which the operation should be
+                // retried (e.g. LockFailure). The retry has good chances to succeed.
+                if (exceptionPredicate.test(t)) {
+                  return true;
+                }
+
+                // Exception hooks may identify additional exceptions for retry.
+                if (exceptionHooks.stream().anyMatch(h -> h.shouldRetry(t))) {
+                  return true;
+                }
+
+                // A non-recoverable failure occurred. Check if we should retry to capture a trace
+                // of the failure. If a trace was already done there is no need to retry.
+                if (retryWithTraceOnFailure
+                    && opts.retryWithTrace().isPresent()
+                    && opts.retryWithTrace().get().test(t)) {
+                  String caller = opts.caller().map(Class::getSimpleName).orElse("N/A");
+                  if (!traceContext.isTracing()) {
+                    String traceId = "retry-on-failure-" + new RequestId();
+                    traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
+                    opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
+                    logger.atFine().withCause(t).log(
+                        "AutoRetry: %s failed, retry with tracing enabled", caller);
+                    metrics.autoRetryCount.increment(actionType, caller);
+                    return true;
+                  }
+
+                  // A non-recoverable failure occurred. We retried the operation with tracing
+                  // enabled and it failed again. Log the failure so that admin can see if it
+                  // differs from the failure that triggered the retry.
+                  logger.atFine().withCause(t).log(
+                      "AutoRetry: auto-retry of %s has failed", caller);
+                  metrics.failuresOnAutoRetryCount.increment(actionType, caller);
+                  return false;
+                }
+
+                return false;
+              });
       retryerBuilder.withRetryListener(listener);
       return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
     } finally {
       if (listener.getAttemptCount() > 1) {
         logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        metrics.attemptCounts.incrementBy(actionType, listener.getAttemptCount() - 1);
       }
-      metrics.attemptCounts.record(actionType, listener.getAttemptCount());
     }
   }
 
@@ -295,7 +387,7 @@
   private <O> RetryerBuilder<O> createRetryerBuilder(
       ActionType actionType, Options opts, Predicate<Throwable> exceptionPredicate) {
     RetryerBuilder<O> retryerBuilder =
-        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate);
+        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate::test);
     if (opts.listener() != null) {
       retryerBuilder.withRetryListener(opts.listener());
     }
diff --git a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
index cd4df45..bce1209 100644
--- a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
+++ b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.update;
 
+import com.google.common.base.Throwables;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.RestResource;
+import java.util.concurrent.atomic.AtomicReference;
 
 public abstract class RetryingRestCollectionModifyView<
         P extends RestResource, C extends RestResource, I, O>
@@ -30,11 +34,25 @@
   }
 
   @Override
-  public final O apply(P parentResource, I input)
+  public final Response<O> apply(P parentResource, I input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    return retryHelper.execute(updateFactory -> applyImpl(updateFactory, parentResource, input));
+    AtomicReference<String> traceId = new AtomicReference<>(null);
+    try {
+      RetryHelper.Options retryOptions =
+          RetryHelper.options()
+              .caller(getClass())
+              .retryWithTrace(t -> !(t instanceof RestApiException))
+              .onAutoTrace(traceId::set)
+              .build();
+      return retryHelper
+          .execute(updateFactory -> applyImpl(updateFactory, parentResource, input), retryOptions)
+          .traceId(traceId.get());
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      return Response.<O>internalServerError(e).traceId(traceId.get());
+    }
   }
 
-  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, P parentResource, I input)
-      throws Exception;
+  protected abstract Response<O> applyImpl(
+      BatchUpdate.Factory updateFactory, P parentResource, I input) throws Exception;
 }
diff --git a/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
index 1b0b1f4..56c3eec 100644
--- a/java/com/google/gerrit/server/update/RetryingRestModifyView.java
+++ b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.update;
 
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestResource;
+import java.util.concurrent.atomic.AtomicReference;
 
 public abstract class RetryingRestModifyView<R extends RestResource, I, O>
     implements RestModifyView<R, I> {
@@ -26,10 +30,24 @@
   }
 
   @Override
-  public final O apply(R resource, I input) throws Exception {
-    return retryHelper.execute(updateFactory -> applyImpl(updateFactory, resource, input));
+  public final Response<O> apply(R resource, I input) throws RestApiException {
+    AtomicReference<String> traceId = new AtomicReference<>(null);
+    try {
+      RetryHelper.Options retryOptions =
+          RetryHelper.options()
+              .caller(getClass())
+              .retryWithTrace(t -> !(t instanceof RestApiException))
+              .onAutoTrace(traceId::set)
+              .build();
+      return retryHelper
+          .execute(updateFactory -> applyImpl(updateFactory, resource, input), retryOptions)
+          .traceId(traceId.get());
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      return Response.<O>internalServerError(e).traceId(traceId.get());
+    }
   }
 
-  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
+  protected abstract Response<O> applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
       throws Exception;
 }
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 4ad226b..1c8ce0c 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -18,8 +18,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Change;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import org.eclipse.jgit.lib.Constants;
@@ -34,7 +34,7 @@
     try {
       rng = SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("Cannot create RNG for Change-Id generator", e);
+      throw new IllegalStateException("Cannot create RNG for Change-Id generator", e);
     }
   }
 
@@ -71,6 +71,6 @@
   }
 
   public static Change.Key generateKey() {
-    return new Change.Key("I" + generateChangeId().name());
+    return Change.key("I" + generateChangeId().name());
   }
 }
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index 276df06..d4c2dc4 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -45,8 +45,8 @@
   public static int mix(int salt, int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v0 += ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
-    v1 += ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
+    v0 += (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
+    v1 += (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
     return result(v0, v1);
   }
 
@@ -54,8 +54,8 @@
   static int unmix(int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v1 -= ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
-    v0 -= ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
+    v1 -= (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
+    v0 -= (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
     return result(v0, v1);
   }
 
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index 4e41be0..924c288 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -16,7 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.lib.Ref;
@@ -26,28 +26,19 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static final String NEW_CHANGE = "refs/for/";
-  // TODO(xchangcheng): remove after 'repo' supports private/wip changes.
-  public static final String NEW_DRAFT_CHANGE = "refs/drafts/";
 
   /** Extracts the destination from a ref name */
   public static String getDestBranchName(String refName) {
-    String magicBranch = NEW_CHANGE;
-    if (refName.startsWith(NEW_DRAFT_CHANGE)) {
-      magicBranch = NEW_DRAFT_CHANGE;
-    }
-    return refName.substring(magicBranch.length());
+    return refName.substring(NEW_CHANGE.length());
   }
 
   /** Checks if the supplied ref name is a magic branch */
   public static boolean isMagicBranch(String refName) {
-    return refName.startsWith(NEW_DRAFT_CHANGE) || refName.startsWith(NEW_CHANGE);
+    return refName.startsWith(NEW_CHANGE);
   }
 
   /** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
   public static String getMagicRefNamePrefix(String refName) {
-    if (refName.startsWith(NEW_DRAFT_CHANGE)) {
-      return NEW_DRAFT_CHANGE;
-    }
     if (refName.startsWith(NEW_CHANGE)) {
       return NEW_CHANGE;
     }
@@ -63,15 +54,7 @@
    * branch.
    */
   public static Capable checkMagicBranchRefs(Repository repo, Project project) {
-    Capable result = checkMagicBranchRef(NEW_CHANGE, repo, project);
-    if (result != Capable.OK) {
-      return result;
-    }
-    result = checkMagicBranchRef(NEW_DRAFT_CHANGE, repo, project);
-    if (result != Capable.OK) {
-      return result;
-    }
-    return Capable.OK;
+    return checkMagicBranchRef(NEW_CHANGE, repo, project);
   }
 
   private static Capable checkMagicBranchRef(String branchName, Repository repo, Project project) {
diff --git a/java/com/google/gerrit/server/util/OneOffRequestContext.java b/java/com/google/gerrit/server/util/OneOffRequestContext.java
index 1788343..62683f0 100644
--- a/java/com/google/gerrit/server/util/OneOffRequestContext.java
+++ b/java/com/google/gerrit/server/util/OneOffRequestContext.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/util/ReplicaUtil.java b/java/com/google/gerrit/server/util/ReplicaUtil.java
new file mode 100644
index 0000000..bf6111a
--- /dev/null
+++ b/java/com/google/gerrit/server/util/ReplicaUtil.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import org.eclipse.jgit.lib.Config;
+
+public class ReplicaUtil {
+  /** Provides backward compatibility for container.slave property. */
+  public static boolean isReplica(Config cfg) {
+    return cfg.getBoolean("container", "slave", false)
+        || cfg.getBoolean("container", "replica", false);
+  }
+}
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 789b9b1..dc8a136 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -17,7 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Throwables;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.inject.Key;
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index a8ae918..4f4ba83 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -5,7 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//java/com/google/gerrit/entities",
+        "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/util/git/DelegateSystemReader.java b/java/com/google/gerrit/server/util/git/DelegateSystemReader.java
new file mode 100644
index 0000000..279bb95
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/DelegateSystemReader.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util.git;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+public class DelegateSystemReader extends SystemReader {
+  private final SystemReader delegate;
+
+  public DelegateSystemReader(SystemReader delegate) {
+    this.delegate = delegate;
+  }
+
+  @Override
+  public String getHostname() {
+    return delegate.getHostname();
+  }
+
+  @Override
+  public String getenv(String variable) {
+    return delegate.getenv(variable);
+  }
+
+  @Override
+  public String getProperty(String key) {
+    return delegate.getProperty(key);
+  }
+
+  @Override
+  public FileBasedConfig openUserConfig(Config parent, FS fs) {
+    return delegate.openUserConfig(parent, fs);
+  }
+
+  @Override
+  public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+    return delegate.openSystemConfig(parent, fs);
+  }
+
+  @Override
+  public FileBasedConfig openJGitConfig(Config parent, FS fs) {
+    return delegate.openJGitConfig(parent, fs);
+  }
+
+  @Override
+  public long getCurrentTime() {
+    return delegate.getCurrentTime();
+  }
+
+  @Override
+  public int getTimezone(long when) {
+    return delegate.getTimezone(when);
+  }
+}
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index f05d1d7..97132a3 100644
--- a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.util.git;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.HashSet;
@@ -45,10 +45,10 @@
 
   private final Config config;
   private final String canonicalWebUrl;
-  private final Branch.NameKey superProjectBranch;
+  private final BranchNameKey superProjectBranch;
 
   public SubmoduleSectionParser(
-      Config config, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
+      Config config, String canonicalWebUrl, BranchNameKey superProjectBranch) {
     this.config = config;
     this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
@@ -81,13 +81,13 @@
         String project;
 
         if (branch.equals(".")) {
-          branch = superProjectBranch.get();
+          branch = superProjectBranch.branch();
         }
 
         // relative URL
         if (url.startsWith("../")) {
           // prefix with a slash for easier relative path walks
-          project = '/' + superProjectBranch.getParentKey().get();
+          project = '/' + superProjectBranch.project().get();
           String hostPart = url;
           while (hostPart.startsWith("../")) {
             int lastSlash = project.lastIndexOf('/');
@@ -133,9 +133,9 @@
                   0, //
                   project.length() - Constants.DOT_GIT_EXT.length());
         }
-        Project.NameKey projectKey = new Project.NameKey(project);
+        Project.NameKey projectKey = Project.nameKey(project);
         return new SubmoduleSubscription(
-            superProjectBranch, new Branch.NameKey(projectKey, branch), path);
+            superProjectBranch, BranchNameKey.create(projectKey, branch), path);
       }
     } catch (URISyntaxException e) {
       // Error in url syntax (in fact it is uri syntax)
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index ea39efe..b1126f0 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -5,7 +5,9 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/server/util/git",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 645dbb9..639d0a6 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.util.time;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.server.util.git.DelegateSystemReader;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.function.LongSupplier;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 
 /** Static utility methods for dealing with dates and times. */
@@ -43,6 +43,17 @@
     return new Timestamp(nowMs());
   }
 
+  /**
+   * Returns the magic timestamp representing no specific time.
+   *
+   * <p>This "null object" is helpful in contexts where using {@code null} directly is not possible.
+   */
+  @UsedAt(Project.PLUGIN_CHECKS)
+  public static Timestamp never() {
+    // Always create a new object as timestamps are mutable.
+    return new Timestamp(0);
+  }
+
   public static Timestamp truncateToSecond(Timestamp t) {
     return new Timestamp((t.getTime() / 1000) * 1000);
   }
@@ -63,47 +74,15 @@
     SystemReader.setInstance(null);
   }
 
-  private static class GerritSystemReader extends SystemReader {
-    SystemReader delegate;
-
-    GerritSystemReader(SystemReader delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getHostname() {
-      return delegate.getHostname();
-    }
-
-    @Override
-    public String getenv(String variable) {
-      return delegate.getenv(variable);
-    }
-
-    @Override
-    public String getProperty(String key) {
-      return delegate.getProperty(key);
-    }
-
-    @Override
-    public FileBasedConfig openUserConfig(Config parent, FS fs) {
-      return delegate.openUserConfig(parent, fs);
-    }
-
-    @Override
-    public FileBasedConfig openSystemConfig(Config parent, FS fs) {
-      return delegate.openSystemConfig(parent, fs);
+  static class GerritSystemReader extends DelegateSystemReader {
+    GerritSystemReader(SystemReader reader) {
+      super(reader);
     }
 
     @Override
     public long getCurrentTime() {
       return currentMillisSupplier.getAsLong();
     }
-
-    @Override
-    public int getTimezone(long when) {
-      return delegate.getTimezone(when);
-    }
   }
 
   private TimeUtil() {}
diff --git a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
index a97ce0b..514125f 100644
--- a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
+++ b/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.validators;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 
 /** Listener to provide validation of assignees. */
 @ExtensionPoint
diff --git a/java/com/google/gerrit/server/validators/HashtagValidationListener.java b/java/com/google/gerrit/server/validators/HashtagValidationListener.java
index fbf8e76..5ea6bfa 100644
--- a/java/com/google/gerrit/server/validators/HashtagValidationListener.java
+++ b/java/com/google/gerrit/server/validators/HashtagValidationListener.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.validators;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Change;
 import java.util.Set;
 
 /** Listener to provide validation of hashtag changes. */
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index f617ebb..8bf6cd5 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -24,11 +24,14 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Argument;
 
 public abstract class AbstractGitCommand extends BaseCommand {
+  private static final String GIT_PROTOCOL = "GIT_PROTOCOL";
+
   @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
   protected ProjectState projectState;
 
@@ -45,9 +48,15 @@
   protected Repository repo;
   protected Project.NameKey projectName;
   protected Project project;
+  protected String[] extraParameters;
 
   @Override
-  public void start(Environment env) {
+  public void start(ChannelSession channel, Environment env) {
+    String gitProtocol = env.getEnv().get(GIT_PROTOCOL);
+    if (gitProtocol != null) {
+      extraParameters = gitProtocol.split(":");
+    }
+
     Context ctx = context.subContext(newSession(), context.getCommandLine());
     final Context old = sshScope.set(ctx);
     try {
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index 567cf00..bf0dd91 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -27,6 +27,7 @@
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 
 /** Command that executes some other command. */
@@ -47,9 +48,9 @@
   }
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     try {
-      begin(env);
+      begin(channel, env);
     } catch (Failure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
@@ -61,7 +62,7 @@
     }
   }
 
-  private void begin(Environment env) throws IOException, Failure {
+  private void begin(ChannelSession channel, Environment env) throws IOException, Failure {
     Map<String, CommandProvider> map = root.getMap();
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
@@ -90,15 +91,15 @@
     }
     provideStateTo(cmd);
     atomicCmd.set(cmd);
-    cmd.start(env);
+    cmd.start(channel, env);
   }
 
   @Override
-  public void destroy() {
+  public void destroy(ChannelSession channel) {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
       try {
-        cmd.destroy();
+        cmd.destroy(channel);
       } catch (Exception e) {
         Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 7de7551..689c567 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -7,27 +7,29 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
-        "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-archive",
         "//lib:jsch",
-        "//lib:servlet-api-3_1",
+        "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcprov-neverlink",
@@ -37,8 +39,6 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",  # SSH should not depend on servlet
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:log4j",
         "//lib/mina:core",
         "//lib/mina:sshd",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 7c77a2c..ab1f062 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -20,10 +20,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
@@ -57,6 +57,7 @@
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
@@ -182,7 +183,7 @@
   }
 
   @Override
-  public void destroy() {
+  public void destroy(ChannelSession channel) {
     Future<?> future = task.getAndSet(null);
     if (future != null && !future.isDone()) {
       future.cancel(true);
@@ -264,7 +265,8 @@
   /**
    * Spawn a function into its own thread.
    *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
+   * <p>Typically this should be invoked within {@link Command#start(ChannelSession, Environment)},
+   * such as:
    *
    * <pre>
    * startThread(new CommandRunnable() {
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index ed6f87f..491bcb8 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -122,7 +122,7 @@
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
     try {
-      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+      return Arrays.asList(Change.id(Integer.parseInt(id)));
     } catch (NumberFormatException e) {
       throw new UnloggedFailure(2, "Invalid change ID " + id, e);
     }
diff --git a/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java b/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java
new file mode 100644
index 0000000..f8ab90e
--- /dev/null
+++ b/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+/**
+ * This file is based on sshd-contrib Apache SSHD Mina project. Original commit:
+ * https://github.com/apache/mina-sshd/commit/11b33dee37b5b9c71a40a8a98a42007e3687131e
+ */
+package com.google.gerrit.sshd;
+
+import com.google.common.flogger.FluentLogger;
+import java.io.IOException;
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.channel.Channel;
+import org.apache.sshd.common.channel.ChannelListener;
+import org.apache.sshd.common.channel.exception.SshChannelNotFoundException;
+import org.apache.sshd.common.session.ConnectionService;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.helpers.DefaultUnknownChannelReferenceHandler;
+import org.apache.sshd.common.util.buffer.Buffer;
+
+/**
+ * Makes sure that the referenced &quot;unknown&quot; channel identifier is one that was assigned in
+ * the past. <B>Note:</B> it relies on the fact that the default {@code ConnectionService}
+ * implementation assigns channels identifiers in ascending order.
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ChannelIdTrackingUnknownChannelReferenceHandler
+    extends DefaultUnknownChannelReferenceHandler implements ChannelListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final AttributeKey<Integer> LAST_CHANNEL_ID_KEY = new AttributeKey<>();
+
+  public static final ChannelIdTrackingUnknownChannelReferenceHandler TRACKER =
+      new ChannelIdTrackingUnknownChannelReferenceHandler();
+
+  public ChannelIdTrackingUnknownChannelReferenceHandler() {
+    super();
+  }
+
+  @Override
+  public void channelInitialized(Channel channel) {
+    int channelId = channel.getId();
+    Session session = channel.getSession();
+    Integer lastTracked = session.setAttribute(LAST_CHANNEL_ID_KEY, channelId);
+    logger.atFine().log(
+        "channelInitialized(%s) updated last tracked channel ID %s => %s",
+        channel, lastTracked, channelId);
+  }
+
+  @Override
+  public Channel handleUnknownChannelCommand(
+      ConnectionService service, byte cmd, int channelId, Buffer buffer) throws IOException {
+    Session session = service.getSession();
+    Integer lastTracked = session.getAttribute(LAST_CHANNEL_ID_KEY);
+    if ((lastTracked != null) && (channelId <= lastTracked.intValue())) {
+      // Use TRACE level in order to avoid messages flooding
+      logger.atFinest().log(
+          "handleUnknownChannelCommand(%s) apply default handling for %s on channel=%s (lastTracked=%s)",
+          session, SshConstants.getCommandMessageName(cmd), channelId, lastTracked);
+      return super.handleUnknownChannelCommand(service, cmd, channelId, buffer);
+    }
+
+    throw new SshChannelNotFoundException(
+        channelId,
+        "Received "
+            + SshConstants.getCommandMessageName(cmd)
+            + " on unassigned channel "
+            + channelId
+            + " (last assigned="
+            + lastTracked
+            + ")");
+  }
+}
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index c003b46..38ac26d 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -40,6 +40,7 @@
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.session.ServerSession;
@@ -91,13 +92,13 @@
 
   @Override
   public CommandFactory get() {
-    return requestCommand -> {
-      String c = requestCommand;
+    return (channelSession, requestCommand) -> {
+      String command = requestCommand;
       SshCreateCommandInterceptor interceptor = createCommandInterceptor.get();
       if (interceptor != null) {
-        c = interceptor.intercept(c);
+        command = interceptor.intercept(command);
       }
-      return new Trampoline(c);
+      return new Trampoline(command);
     };
   }
 
@@ -148,7 +149,7 @@
     }
 
     @Override
-    public void start(Environment env) throws IOException {
+    public void start(ChannelSession channel, Environment env) throws IOException {
       this.env = env;
       final Context ctx = this.ctx;
       task.set(
@@ -157,7 +158,7 @@
                 @Override
                 public void run() {
                   try {
-                    onStart();
+                    onStart(channel);
                   } catch (Exception e) {
                     logger.atWarning().withCause(e).log(
                         "Cannot start command \"%s\" for user %s",
@@ -172,7 +173,7 @@
               }));
     }
 
-    private void onStart() throws IOException {
+    private void onStart(ChannelSession channel) throws IOException {
       synchronized (this) {
         final Context old = sshScope.set(ctx);
         try {
@@ -195,7 +196,7 @@
                   log(rc);
                 }
               });
-          cmd.start(env);
+          cmd.start(channel, env);
         } finally {
           sshScope.set(old);
         }
@@ -231,20 +232,20 @@
     }
 
     @Override
-    public void destroy() {
+    public void destroy(ChannelSession channel) {
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(this::onDestroy);
+        destroyExecutor.execute(() -> onDestroy(channel));
       }
     }
 
-    private void onDestroy() {
+    private void onDestroy(ChannelSession channel) {
       synchronized (this) {
         if (cmd != null) {
           final Context old = sshScope.set(ctx);
           try {
-            cmd.destroy();
+            cmd.destroy(channel);
             log(BaseCommand.STATUS_CANCEL);
           } finally {
             ctx = null;
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 1e32e1b..6c0f3af 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -31,6 +31,7 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.PublicKey;
 import java.util.Collection;
@@ -80,19 +81,24 @@
   }
 
   private static Set<PublicKey> myHostKeys(KeyPairProvider p) {
-    final Set<PublicKey> keys = new HashSet<>(6);
-    addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
-    addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
-    addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
+    Set<PublicKey> keys = new HashSet<>(6);
+    try {
+      addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
+      addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
+      addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
+    } catch (IOException | GeneralSecurityException e) {
+      throw new IllegalStateException("Cannot load SSHD host key", e);
+    }
+
     return keys;
   }
 
-  private static void addPublicKey(
-      final Collection<PublicKey> out, KeyPairProvider p, String type) {
-    final KeyPair pair = p.loadKey(type);
+  private static void addPublicKey(Collection<PublicKey> out, KeyPairProvider p, String type)
+      throws IOException, GeneralSecurityException {
+    KeyPair pair = p.loadKey(null, type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
     }
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 68962db..7db65bd 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -33,6 +33,7 @@
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 
@@ -69,7 +70,7 @@
   }
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     try {
       parseCommandLine();
       if (Strings.isNullOrEmpty(commandName)) {
@@ -115,7 +116,7 @@
 
       provideStateTo(cmd);
       atomicCmd.set(cmd);
-      cmd.start(env);
+      cmd.start(channel, env);
 
     } catch (UnloggedFailure e) {
       String msg = e.getMessage();
@@ -145,11 +146,11 @@
   }
 
   @Override
-  public void destroy() {
+  public void destroy(ChannelSession channel) {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
       try {
-        cmd.destroy();
+        cmd.destroy(channel);
       } catch (Exception e) {
         Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
diff --git a/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
index adb5085..6759275 100644
--- a/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
+++ b/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.account.AccountCache;
@@ -66,7 +66,7 @@
     }
 
     Optional<Account> account =
-        accounts.getByUsername(username).map(AccountState::getAccount).filter(Account::isActive);
+        accounts.getByUsername(username).map(AccountState::account).filter(Account::isActive);
     if (!account.isPresent()) {
       return false;
     }
@@ -77,6 +77,6 @@
         sshScope,
         sshLog,
         sd,
-        SshUtil.createUser(sd, userFactory, account.get().getId()));
+        SshUtil.createUser(sd, userFactory, account.get().id()));
   }
 }
diff --git a/java/com/google/gerrit/sshd/HostKeyProvider.java b/java/com/google/gerrit/sshd/HostKeyProvider.java
index bffcfcd..3578fb9 100644
--- a/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -18,7 +18,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
-import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -44,21 +43,21 @@
     Path ecdsaKey_521 = site.ssh_ecdsa_521;
     Path ed25519Key = site.ssh_ed25519;
 
-    final List<File> stdKeys = new ArrayList<>(6);
+    final List<Path> stdKeys = new ArrayList<>(6);
     if (Files.exists(rsaKey)) {
-      stdKeys.add(rsaKey.toAbsolutePath().toFile());
+      stdKeys.add(rsaKey);
     }
     if (Files.exists(ecdsaKey_256)) {
-      stdKeys.add(ecdsaKey_256.toAbsolutePath().toFile());
+      stdKeys.add(ecdsaKey_256);
     }
     if (Files.exists(ecdsaKey_384)) {
-      stdKeys.add(ecdsaKey_384.toAbsolutePath().toFile());
+      stdKeys.add(ecdsaKey_384);
     }
     if (Files.exists(ecdsaKey_521)) {
-      stdKeys.add(ecdsaKey_521.toAbsolutePath().toFile());
+      stdKeys.add(ecdsaKey_521);
     }
     if (Files.exists(ed25519Key)) {
-      stdKeys.add(ed25519Key.toAbsolutePath().toFile());
+      stdKeys.add(ed25519Key);
     }
 
     if (Files.exists(objKey)) {
@@ -70,14 +69,14 @@
       // Both formats of host key exist, we don't know which format
       // should be authoritative. Complain and abort.
       //
-      stdKeys.add(objKey.toAbsolutePath().toFile());
+      stdKeys.add(objKey);
       throw new ProvisionException("Multiple host keys exist: " + stdKeys);
     }
     if (stdKeys.isEmpty()) {
       throw new ProvisionException("No SSH keys under " + site.etc_dir);
     }
     FileKeyPairProvider kp = new FileKeyPairProvider();
-    kp.setFiles(stdKeys);
+    kp.setPaths(stdKeys);
     return kp;
   }
 }
diff --git a/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
index 5aaa647..dd31e4c 100644
--- a/java/com/google/gerrit/sshd/NoShell.java
+++ b/java/com/google/gerrit/sshd/NoShell.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -27,12 +27,13 @@
 import java.io.OutputStream;
 import java.net.MalformedURLException;
 import java.net.URL;
-import org.apache.sshd.common.Factory;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.shell.ShellFactory;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -42,7 +43,7 @@
  * <p>This implementation is used to ensure clients who try to SSH directly to this server without
  * supplying a command will get a reasonable error message, but cannot continue further.
  */
-class NoShell implements Factory<Command> {
+class NoShell implements ShellFactory {
   private final Provider<SendMessage> shell;
 
   @Inject
@@ -51,7 +52,7 @@
   }
 
   @Override
-  public Command create() {
+  public Command createShell(ChannelSession channel) {
     return shell.get();
   }
 
@@ -98,7 +99,7 @@
     }
 
     @Override
-    public void start(Environment env) throws IOException {
+    public void start(ChannelSession channel, Environment env) throws IOException {
       Context old = sshScope.set(context);
       String message;
       try {
@@ -116,7 +117,7 @@
     }
 
     @Override
-    public void destroy() {}
+    public void destroy(ChannelSession channel) {}
   }
 
   static class MessageFactory {
@@ -145,7 +146,7 @@
       msg.append("\r\n");
 
       Account account = user.getAccount();
-      String name = account.getFullName();
+      String name = account.fullName();
       if (name == null || name.isEmpty()) {
         name = user.getUserName().orElse(anonymousCowardName);
       }
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 9828957..e60ba6d 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,14 +14,28 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+  @Inject private PluginSetContext<RequestListener> requestListeners;
+  @Inject @GerritServerConfig private Config config;
+
   @Option(name = "--trace", usage = "enable request tracing")
   private boolean trace;
 
@@ -32,13 +46,18 @@
   protected PrintWriter stderr;
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     startThread(
         () -> {
           parseCommandLine();
           stdout = toPrintWriter(out);
           stderr = toPrintWriter(err);
-          try (TraceContext traceContext = enableTracing()) {
+          try (TraceContext traceContext = enableTracing();
+              PerformanceLogContext performanceLogContext =
+                  new PerformanceLogContext(config, performanceLoggers)) {
+            RequestInfo requestInfo =
+                RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+            requestListeners.runEach(l -> l.onRequest(requestInfo));
             SshCommand.this.run();
           } finally {
             stdout.flush();
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 69176a2..da09087 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -51,6 +51,7 @@
 import java.nio.file.WatchService;
 import java.nio.file.attribute.UserPrincipalLookupService;
 import java.nio.file.spi.FileSystemProvider;
+import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.KeyPair;
 import java.security.PublicKey;
@@ -208,6 +209,7 @@
     final boolean enableCompression = cfg.getBoolean("sshd", "enableCompression", false);
 
     SshSessionBackend backend = cfg.getEnum("sshd", null, "backend", SshSessionBackend.NIO2);
+    boolean channelIdTracking = cfg.getBoolean("sshd", "enableChannelIdTracking", true);
 
     System.setProperty(
         IoServiceFactoryFactory.class.getName(),
@@ -221,7 +223,7 @@
     initMacs(cfg);
     initSignatures();
     initChannels();
-    initUnknownChannelReferenceHandler();
+    initUnknownChannelReferenceHandler(channelIdTracking);
     initForwarding();
     initFileSystemFactory();
     initSubsystems();
@@ -381,12 +383,12 @@
       return Collections.emptyList();
     }
 
-    final List<PublicKey> keys = myHostKeys();
-    final List<HostKey> r = new ArrayList<>();
+    List<HostKey> r = new ArrayList<>();
+    List<PublicKey> keys = myHostKeys();
     for (PublicKey pub : keys) {
-      final Buffer buf = new ByteArrayBuffer();
+      Buffer buf = new ByteArrayBuffer();
       buf.putRawPublicKey(pub);
-      final byte[] keyBin = buf.getCompactData();
+      byte[] keyBin = buf.getCompactData();
 
       for (String addr : advertised) {
         try {
@@ -397,24 +399,29 @@
         }
       }
     }
+
     return Collections.unmodifiableList(r);
   }
 
   private List<PublicKey> myHostKeys() {
-    final KeyPairProvider p = getKeyPairProvider();
-    final List<PublicKey> keys = new ArrayList<>(6);
-    addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
-    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
-    addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
-    addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
+    KeyPairProvider p = getKeyPairProvider();
+    List<PublicKey> keys = new ArrayList<>(6);
+    try {
+      addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
+      addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
+      addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
+      addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
+    } catch (IOException | GeneralSecurityException e) {
+      throw new IllegalStateException("Cannot load SSHD host key", e);
+    }
     return keys;
   }
 
-  private static void addPublicKey(
-      final Collection<PublicKey> out, KeyPairProvider p, String type) {
-    final KeyPair pair = p.loadKey(type);
+  private static void addPublicKey(final Collection<PublicKey> out, KeyPairProvider p, String type)
+      throws IOException, GeneralSecurityException {
+    final KeyPair pair = p.loadKey(null, type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
     }
@@ -514,14 +521,14 @@
 
   @SuppressWarnings("unchecked")
   private void initCiphers(Config cfg) {
-    final List<NamedFactory<Cipher>> a = BaseBuilder.setUpDefaultCiphers(true);
+    List<NamedFactory<Cipher>> a = BaseBuilder.setUpDefaultCiphers(true);
 
     for (Iterator<NamedFactory<Cipher>> i = a.iterator(); i.hasNext(); ) {
-      final NamedFactory<Cipher> f = i.next();
+      NamedFactory<Cipher> f = i.next();
       try {
-        final Cipher c = f.create();
-        final byte[] key = new byte[c.getBlockSize()];
-        final byte[] iv = new byte[c.getIVSize()];
+        Cipher c = f.create();
+        byte[] key = new byte[c.getKdfSize()];
+        byte[] iv = new byte[c.getIVSize()];
         c.init(Cipher.Mode.Encrypt, key, iv);
       } catch (InvalidKeyException e) {
         logger.atWarning().log(
@@ -614,7 +621,8 @@
   }
 
   private void initSignatures() {
-    setSignatureFactories(BaseBuilder.setUpDefaultSignatures(true));
+    setSignatureFactories(
+        NamedFactory.setUpBuiltinFactories(false, ServerBuilder.DEFAULT_SIGNATURE_PREFERENCE));
   }
 
   private void initCompression(boolean enableCompression) {
@@ -630,10 +638,9 @@
     // However, if there are CPU in abundance and the server is reachable through
     // slow networks, gits with huge amount of refs can benefit from SSH-compression
     // since git does not compress the ref announcement during the handshake.
-    //
-    // Compression can be especially useful when Gerrit slaves are being used
-    // for the larger clones and fetches and the master server mostly takes small
-    // receive-packs.
+    // Compression can be especially useful when Gerrit replica are being used
+    // for the larger clones and fetches and the primary server handling write
+    // operations mostly takes small receive-packs.
 
     if (enableCompression) {
       compressionFactories.add(BuiltinCompressions.zlib);
@@ -646,8 +653,11 @@
     setChannelFactories(ServerBuilder.DEFAULT_CHANNEL_FACTORIES);
   }
 
-  private void initUnknownChannelReferenceHandler() {
-    setUnknownChannelReferenceHandler(DefaultUnknownChannelReferenceHandler.INSTANCE);
+  private void initUnknownChannelReferenceHandler(boolean enableChannelIdTracking) {
+    setUnknownChannelReferenceHandler(
+        enableChannelIdTracking
+            ? ChannelIdTrackingUnknownChannelReferenceHandler.TRACKER
+            : DefaultUnknownChannelReferenceHandler.INSTANCE);
   }
 
   private void initSubsystems() {
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheEntry.java b/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
index 8e962e3..78fd7da 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import java.security.PublicKey;
 
 class SshKeyCacheEntry {
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index fb0b8f6..773c25b 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -106,7 +107,9 @@
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (TraceTimer timer =
-          TraceContext.newTimer("Loading SSH keys for account with username %s", username)) {
+          TraceContext.newTimer(
+              "Loading SSH keys for account with username",
+              Metadata.builder().username(username).build())) {
         Optional<ExternalId> user =
             externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
         if (!user.isPresent()) {
diff --git a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
index b2853b9..02a9e48 100644
--- a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.ssh.SshKeyCreator;
 import java.security.NoSuchAlgorithmException;
diff --git a/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index 1a60a20..d6ecc73 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -19,7 +19,7 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
-import org.apache.sshd.common.AttributeStore.AttributeKey;
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
 
 /** Global data related to an active SSH connection. */
 public class SshSession {
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index 670d64c..39366f0 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index 6d0bf2b..ea163d5 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -18,8 +18,8 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
@@ -35,6 +35,7 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
@@ -90,7 +91,7 @@
   }
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     try {
       checkCanRunAs();
       parseCommandLine();
@@ -102,7 +103,7 @@
         cmd.setArguments(args.toArray(new String[args.size()]));
         provideStateTo(cmd);
         atomicCmd.set(cmd);
-        cmd.start(env);
+        cmd.start(channel, env);
       } finally {
         sshScope.set(old);
       }
@@ -158,11 +159,11 @@
   }
 
   @Override
-  public void destroy() {
+  public void destroy(ChannelSession channel) {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
       try {
-        cmd.destroy();
+        cmd.destroy(channel);
       } catch (Exception e) {
         Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index 415ac4c..ee6f635 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -68,7 +68,7 @@
           BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
       input.reason = reason;
 
-      BanResultInfo r = banCommit.apply(new ProjectResource(projectState, user), input);
+      BanResultInfo r = banCommit.apply(new ProjectResource(projectState, user), input).value();
       printCommits(r.newlyBanned, "The following commits were banned");
       printCommits(r.alreadyBanned, "The following commits were already banned");
       printCommits(r.ignored, "The following ids do not represent commits and were ignored");
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 8875f07..004a0ba 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -18,12 +18,12 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.CreateAccount;
 import com.google.gerrit.sshd.CommandMetaData;
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index f9a04a0..9f420ed 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -17,14 +17,14 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers;
@@ -114,11 +114,12 @@
       }
     } catch (RestApiException e) {
       throw die(e);
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
-  private GroupResource createGroup()
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  private GroupResource createGroup() throws Exception {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -128,7 +129,9 @@
     }
 
     GroupInfo group =
-        createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName), input);
+        createGroup
+            .apply(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName), input)
+            .value();
     return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index df86d63..fca7427 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -18,6 +18,8 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -25,8 +27,6 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SuggestParentCandidates;
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 87b6f02..905ba9c 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
+import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.CommandName;
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 3a06660..2afc009 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.config.ListCaches;
 import com.google.gerrit.server.restapi.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.restapi.config.PostCaches;
@@ -81,15 +80,16 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
-    } catch (PermissionBackendException e) {
+    } catch (Exception e) {
       throw new Failure(1, "unavailable", e);
     }
   }
 
   @SuppressWarnings("unchecked")
-  private void doList() {
+  private void doList() throws Exception {
     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/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index ecbb373..2073087 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -21,8 +21,8 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index 5aa2ec8..209c442 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.change.Index;
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index f3ba308..bdf5412 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -18,9 +18,9 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
@@ -62,7 +62,7 @@
       if (verboseOutput) {
         Optional<InternalGroup> group =
             info.ownerId != null
-                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                ? groupCache.get(AccountGroup.uuid(Url.decode(info.ownerId)))
                 : Optional.empty();
 
         formatter.addColumn(Url.decode(info.id));
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 1565ecb..dc1bc6e 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -18,8 +18,8 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
@@ -69,7 +69,7 @@
     }
 
     void display(PrintWriter writer) throws PermissionBackendException {
-      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
+      Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(name));
       String errorText = "Group not found or not visible\n";
 
       if (!group.isPresent()) {
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index f26a4fa..1a60679 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -17,12 +17,12 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -76,7 +76,7 @@
   protected void run() throws Failure {
     Account.Id userAccountId;
     try {
-      userAccountId = accountResolver.resolve(userName).asUnique().getAccount().getId();
+      userAccountId = accountResolver.resolve(userName).asUnique().account().id();
     } catch (UnprocessableEntityException e) {
       stdout.println(e.getMessage());
       stdout.flush();
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index 45210f6..f804c2d 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -56,7 +56,7 @@
       throws UnloggedFailure {
     // By commit?
     //
-    if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
+    if (token.matches("^([0-9a-fA-F]{4," + ObjectIds.STR_LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
       if (projectState != null) {
@@ -76,7 +76,7 @@
           continue;
         }
         for (PatchSet ps : cd.patchSets()) {
-          if (ps.getRevision().matches(token)) {
+          if (ObjectIds.matchesAbbreviation(ps.commitId(), token)) {
             matches.add(ps);
           }
         }
@@ -101,7 +101,7 @@
       } catch (IllegalArgumentException e) {
         throw error("\"" + token + "\" is not a valid patch set", e);
       }
-      ChangeNotes notes = getNotes(projectState, patchSetId.getParentKey());
+      ChangeNotes notes = getNotes(projectState, patchSetId.changeId());
       PatchSet patchSet = psUtil.get(notes, patchSetId);
       if (patchSet == null) {
         throw error("\"" + token + "\" no such patch set");
@@ -147,7 +147,7 @@
       // No --branch option, so they want every branch.
       return true;
     }
-    return change.getDest().get().equals(branch);
+    return change.getDest().branch().equals(branch);
   }
 
   public static UnloggedFailure error(String msg) {
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 15142bd..e5dad7e 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -41,7 +41,7 @@
 
   @Override
   public void run() throws Exception {
-    Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE);
+    Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
 
     if (format.isJson()) {
       format
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 0b089b6..92666f3 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -18,10 +18,9 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -31,12 +30,8 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.errors.UnpackException;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.Option;
@@ -118,52 +113,17 @@
         logger.atInfo().log(msg.toString());
         throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
       }
-
-      // This may have been triggered by branch level access controls.
-      // Log what the heck is going on, as detailed as we can.
-      //
       StringBuilder msg = new StringBuilder();
       msg.append("Unpack error on project \"").append(projectState.getName()).append("\":\n");
 
       msg.append("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
       if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
         msg.append("DEFAULT");
-      } else if (rp.getAdvertiseRefsHook() instanceof DefaultAdvertiseRefsHook) {
-        msg.append("DefaultAdvertiseRefsHook");
       } else {
         msg.append(rp.getAdvertiseRefsHook().getClass());
       }
       msg.append("\n");
 
-      if (rp.getAdvertiseRefsHook() instanceof DefaultAdvertiseRefsHook) {
-        Map<String, Ref> adv = rp.getAdvertisedRefs();
-        msg.append("  Visible references (").append(adv.size()).append("):\n");
-        for (Ref ref : adv.values()) {
-          msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
-              .append(" ")
-              .append(ref.getName())
-              .append("\n");
-        }
-
-        List<Ref> allRefs = rp.getRepository().getRefDatabase().getRefs();
-        List<Ref> hidden = new ArrayList<>();
-        for (Ref ref : allRefs) {
-          if (!adv.containsKey(ref.getName())) {
-            hidden.add(ref);
-          }
-        }
-
-        msg.append("  Hidden references (").append(hidden.size()).append("):\n");
-        for (Ref ref : hidden) {
-          msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
-              .append(" ")
-              .append(ref.getName())
-              .append("\n");
-        }
-      }
-
       IOException detail = new IOException(msg.toString(), badStream);
       throw new Failure(128, "fatal: Unpack error, check server log", detail);
     }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 0af8936..3a1bf6c 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -19,10 +19,12 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -34,7 +36,6 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -227,11 +228,11 @@
         writeError("error", e.getMessage() + "\n");
       } catch (NoSuchChangeException e) {
         ok = false;
-        writeError("error", "no such change " + patchSet.getId().getParentKey().get());
+        writeError("error", "no such change " + patchSet.id().changeId().get());
       } catch (Exception e) {
         ok = false;
-        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
-        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.getId());
+        writeError("fatal", "internal server error while reviewing " + patchSet.id() + "\n");
+        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.id());
       }
     }
 
@@ -242,8 +243,8 @@
 
   private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
     gApi.changes()
-        .id(patchSet.getId().getParentKey().get())
-        .revision(patchSet.getRevision().get())
+        .id(patchSet.id().changeId().get())
+        .revision(patchSet.commitId().name())
         .review(review);
   }
 
@@ -310,11 +311,11 @@
   }
 
   private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.getId().getParentKey().get());
+    return gApi.changes().id(patchSet.id().changeId().get());
   }
 
   private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.getRevision().get());
+    return changeApi(patchSet).revision(patchSet.commitId().name());
   }
 
   @Override
@@ -349,15 +350,15 @@
   private static Option newApproveOption(LabelType type, String usage) {
     return OptionUtil.newOption(
         asOptionName(type),
-        new String[0],
+        ImmutableList.of(),
         usage,
         "N",
         false,
         false,
         false,
         LabelHandler.class,
-        new String[0],
-        new String[0]);
+        ImmutableList.of(),
+        ImmutableList.of());
   }
 
   private static class LabelSetter implements Setter<Short> {
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 5122b35..6912795 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 
 final class ScpCommand extends BaseCommand {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -81,7 +82,7 @@
   }
 
   @Override
-  public void start(Environment env) {
+  public void start(ChannelSession channel, Environment env) {
     startThread(this::runImp, AccessPath.SSH_COMMAND);
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 8a72de6..df1e3ed 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
@@ -31,7 +32,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -211,8 +211,7 @@
     }
   }
 
-  private void setAccount()
-      throws IOException, UnloggedFailure, ConfigInvalidException, PermissionBackendException {
+  private void setAccount() throws Failure {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user.asIdentifiedUser());
     try {
@@ -267,6 +266,8 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
@@ -279,10 +280,8 @@
     }
   }
 
-  private void deleteSshKeys(List<String> sshKeys)
-      throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
+  private void deleteSshKeys(List<String> sshKeys) throws Exception {
+    List<SshKeyInfo> infos = getSshKeys.apply(rsrc).value();
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
         deleteSshKey(i);
@@ -318,10 +317,9 @@
     }
   }
 
-  private void deleteEmail(String email)
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  private void deleteEmail(String email) throws Exception {
     if (email.equals("ALL")) {
-      List<EmailInfo> emails = getEmails.apply(rsrc);
+      List<EmailInfo> emails = getEmails.apply(rsrc).value();
       for (EmailInfo e : emails) {
         deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
       }
@@ -330,9 +328,8 @@
     }
   }
 
-  private void putPreferred(String email)
-      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
-    for (EmailInfo e : getEmails.apply(rsrc)) {
+  private void putPreferred(String email) throws Exception {
+    for (EmailInfo e : getEmails.apply(rsrc).value()) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
         return;
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 324257b..2511df4 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -19,11 +19,11 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupCache;
@@ -140,7 +140,7 @@
                     return "n/a";
                   }
                   return MoreObjects.firstNonNull(
-                      accountState.get().getAccount().getPreferredEmail(), "n/a");
+                      accountState.get().account().preferredEmail(), "n/a");
                 })
             .collect(joining(", "));
     out.write(
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index dfdf7f2..47a61da 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -16,16 +16,14 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectResource;
@@ -112,7 +110,7 @@
         childProjects.addAll(getChildrenForReparenting(oldParent));
       } catch (PermissionBackendException e) {
         throw new Failure(1, "permissions unavailable", e);
-      } catch (StorageException | RestApiException e) {
+      } catch (Exception e) {
         throw new Failure(1, "failure in request", e);
       }
     }
@@ -148,8 +146,7 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent)
-      throws PermissionBackendException, RestApiException {
+  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent) throws Exception {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
     for (ProjectState excludedChild : excludedChildren) {
@@ -159,8 +156,8 @@
     if (newParentKey != null) {
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
-    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user))) {
-      final Project.NameKey childName = new Project.NameKey(child.name);
+    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user)).value()) {
+      final Project.NameKey childName = Project.nameKey(child.name);
       if (!excluded.contains(childName)) {
         if (!automaticallyExcluded.contains(childName)) {
           childProjects.add(childName);
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 30caa43..3d1723b 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -141,7 +141,7 @@
       input.confirmed = true;
       String error;
       try {
-        error = postReviewers.apply(changeRsrc, input).error;
+        error = postReviewers.apply(changeRsrc, input).value().error;
       } catch (Exception e) {
         error = String.format("could not add %s: %s", reviewer, e.getMessage());
       }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index f2a9831..fdb57ac 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -51,6 +51,7 @@
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
 
 /** Show the current cache states. */
@@ -97,7 +98,7 @@
   private int nw;
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     String s = env.getEnv().get(Environment.ENV_COLUMNS);
     if (s != null && !s.isEmpty()) {
       try {
@@ -106,11 +107,11 @@
         columns = 80;
       }
     }
-    super.start(env);
+    super.start(channel, env);
   }
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     nw = columns - 50;
     Date now = new Date();
     stdout.format(
@@ -161,39 +162,45 @@
     }
     stdout.print("+---------------------+---------+---------+\n");
 
-    Collection<CacheInfo> caches = getCaches();
-    printMemoryCoreCaches(caches);
-    printMemoryPluginCaches(caches);
-    printDiskCaches(caches);
-    stdout.print('\n');
-
-    boolean showJvm;
     try {
-      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
-      showJvm = true;
-    } catch (AuthException | PermissionBackendException e) {
-      // Silently ignore and do not display detailed JVM information.
-      showJvm = false;
-    }
-    if (showJvm) {
-      sshSummary();
+      Collection<CacheInfo> caches = getCaches();
+      printMemoryCoreCaches(caches);
+      printMemoryPluginCaches(caches);
+      printDiskCaches(caches);
+      stdout.print('\n');
 
-      SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
-      taskSummary(summary.taskSummary);
-      memSummary(summary.memSummary);
-      threadSummary(summary.threadSummary);
-
-      if (showJVM && summary.jvmSummary != null) {
-        jvmSummary(summary.jvmSummary);
+      boolean showJvm;
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+        showJvm = true;
+      } catch (AuthException | PermissionBackendException e) {
+        // Silently ignore and do not display detailed JVM information.
+        showJvm = false;
       }
+      if (showJvm) {
+        sshSummary();
+
+        SummaryInfo summary =
+            getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource()).value();
+        taskSummary(summary.taskSummary);
+        memSummary(summary.memSummary);
+        threadSummary(summary.threadSummary);
+
+        if (showJVM && summary.jvmSummary != null) {
+          jvmSummary(summary.jvmSummary);
+        }
+      }
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     stdout.flush();
   }
 
-  private Collection<CacheInfo> getCaches() {
+  private Collection<CacheInfo> getCaches() throws Exception {
     @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/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d579ef6..decf5d5 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -44,6 +44,7 @@
 import org.apache.sshd.common.io.nio2.Nio2Acceptor;
 import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
 
 /** Show the current SSH connections. */
@@ -71,7 +72,7 @@
   private int columns = 80;
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     String s = env.getEnv().get(Environment.ENV_COLUMNS);
     if (s != null && !s.isEmpty()) {
       try {
@@ -80,7 +81,7 @@
         columns = 80;
       }
     }
-    super.start(env);
+    super.start(channel, env);
   }
 
   @Override
@@ -148,7 +149,7 @@
     }
 
     stdout.print("--\n");
-    stdout.print("SSHD Backend: " + getBackend() + "\n");
+    stdout.print(String.format(" %d connections; SSHD Backend: %s\n", list.size(), getBackend()));
   }
 
   private String getBackend() {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 2a7bd6e..2ec9e2d 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -40,6 +40,7 @@
 import java.util.List;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
 
 /** Display the current work queue. */
@@ -70,7 +71,7 @@
   private int maxCommandWidth;
 
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     String s = env.getEnv().get(Environment.ENV_COLUMNS);
     if (s != null && !s.isEmpty()) {
       try {
@@ -79,7 +80,7 @@
         columns = 80;
       }
     }
-    super.start(env);
+    super.start(channel, env);
   }
 
   @Override
@@ -94,11 +95,13 @@
 
     List<TaskInfo> tasks;
     try {
-      tasks = listTasks.apply(new ConfigResource());
+      tasks = listTasks.apply(new ConfigResource()).value();
     } catch (AuthException e) {
       throw die(e);
     } catch (PermissionBackendException e) {
       throw new Failure(1, "permission backend unavailable", e);
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index ffd98d5..45540a0 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -16,26 +16,22 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Supplier;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventGson;
 import com.google.gerrit.server.events.EventTypes;
-import com.google.gerrit.server.events.ProjectNameKeySerializer;
-import com.google.gerrit.server.events.SupplierSerializer;
 import com.google.gerrit.server.events.UserScopedEventListener;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.StreamCommandExecutor;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -45,11 +41,12 @@
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
 @CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
-final class StreamEvents extends BaseCommand {
+public final class StreamEvents extends BaseCommand {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Maximum number of events that may be queued up for each connection. */
@@ -71,11 +68,11 @@
 
   @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
 
+  @Inject @EventGson private Gson gson;
+
   /** Queue of events to stream to the connected user. */
   private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
 
-  private Gson gson;
-
   private RegistrationHandle eventListenerRegistration;
 
   /** Special event to notify clients they missed other events. */
@@ -91,29 +88,6 @@
     EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
   }
 
-  private final CancelableRunnable writer =
-      new CancelableRunnable() {
-        @Override
-        public void run() {
-          writeEvents();
-        }
-
-        @Override
-        public void cancel() {
-          onExit(0);
-        }
-
-        @Override
-        public String toString() {
-          StringBuilder b = new StringBuilder();
-          b.append("Stream Events");
-          if (currentUser.getUserName().isPresent()) {
-            b.append(" (").append(currentUser.getUserName().get()).append(")");
-          }
-          return b.toString();
-        }
-      };
-
   /** True if {@link DroppedOutputEvent} needs to be sent. */
   private volatile boolean dropped;
 
@@ -131,10 +105,8 @@
    */
   private Future<?> task;
 
-  private PrintWriter stdout;
-
   @Override
-  public void start(Environment env) throws IOException {
+  public void start(ChannelSession channel, Environment env) throws IOException {
     try {
       parseCommandLine();
     } catch (UnloggedFailure e) {
@@ -148,7 +120,30 @@
       return;
     }
 
-    stdout = toPrintWriter(out);
+    PrintWriter stdout = toPrintWriter(out);
+    CancelableRunnable writer =
+        new CancelableRunnable() {
+          @Override
+          public void run() {
+            writeEvents(this, stdout);
+          }
+
+          @Override
+          public void cancel() {
+            onExit(0);
+          }
+
+          @Override
+          public String toString() {
+            StringBuilder b = new StringBuilder();
+            b.append("Stream Events");
+            if (currentUser.getUserName().isPresent()) {
+              b.append(" (").append(currentUser.getUserName().get()).append(")");
+            }
+            return b.toString();
+          }
+        };
+
     eventListenerRegistration =
         eventListeners.add(
             "gerrit",
@@ -156,7 +151,7 @@
               @Override
               public void onEvent(Event event) {
                 if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(event);
+                  offer(writer, event);
                 }
               }
 
@@ -165,12 +160,6 @@
                 return currentUser;
               }
             });
-
-    gson =
-        new GsonBuilder()
-            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-            .create();
   }
 
   private void removeEventListenerRegistration() {
@@ -191,7 +180,7 @@
   }
 
   @Override
-  public void destroy() {
+  public void destroy(ChannelSession channel) {
     removeEventListenerRegistration();
 
     final boolean exit;
@@ -209,7 +198,7 @@
     }
   }
 
-  private void offer(Event event) {
+  private void offer(CancelableRunnable writer, Event event) {
     synchronized (taskLock) {
       if (!queue.offer(event)) {
         dropped = true;
@@ -231,7 +220,7 @@
     }
   }
 
-  private void writeEvents() {
+  private void writeEvents(CancelableRunnable writer, PrintWriter stdout) {
     int processed = 0;
 
     while (processed < BATCH_SIZE) {
@@ -241,13 +230,13 @@
         // accepting output. Either way terminate this instance.
         //
         removeEventListenerRegistration();
-        flush();
+        flush(stdout);
         onExit(0);
         return;
       }
 
       if (dropped) {
-        write(new DroppedOutputEvent());
+        write(stdout, new DroppedOutputEvent());
         dropped = false;
       }
 
@@ -256,11 +245,11 @@
         break;
       }
 
-      write(event);
+      write(stdout, event);
       processed++;
     }
 
-    flush();
+    flush(stdout);
 
     if (BATCH_SIZE <= processed) {
       // We processed the limit, but more might remain in the queue.
@@ -273,7 +262,7 @@
     }
   }
 
-  private void write(Object message) {
+  private void write(PrintWriter stdout, Object message) {
     String msg = null;
     try {
       msg = gson.toJson(message) + "\n";
@@ -287,7 +276,7 @@
     }
   }
 
-  private void flush() {
+  private void flush(PrintWriter stdout) {
     synchronized (stdout) {
       stdout.flush();
     }
diff --git a/java/com/google/gerrit/sshd/commands/Upload.java b/java/com/google/gerrit/sshd/commands/Upload.java
index e195098..b80b879 100644
--- a/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/java/com/google/gerrit/sshd/commands/Upload.java
@@ -14,22 +14,29 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.git.PermissionAwareRepositoryManager;
+import com.google.gerrit.server.git.TracingHook;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.UsersSelfAdvertiseRefsHook;
 import com.google.gerrit.server.git.validators.UploadValidationException;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.storage.pack.PackStatistics;
 import org.eclipse.jgit.transport.PostUploadHook;
 import org.eclipse.jgit.transport.PostUploadHookChain;
@@ -43,8 +50,10 @@
   @Inject private DynamicSet<PreUploadHook> preUploadHooks;
   @Inject private DynamicSet<PostUploadHook> postUploadHooks;
   @Inject private DynamicSet<UploadPackInitializer> uploadPackInitializers;
+  @Inject private PluginSetContext<RequestListener> requestListeners;
   @Inject private UploadValidators.Factory uploadValidatorsFactory;
   @Inject private PermissionBackend permissionBackend;
+  @Inject private UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook;
 
   private PackStatistics stats;
 
@@ -53,7 +62,6 @@
     PermissionBackend.ForProject perm =
         permissionBackend.user(user).project(projectState.getNameKey());
     try {
-
       perm.check(ProjectPermission.RUN_UPLOAD_PACK);
     } catch (AuthException e) {
       throw new Failure(1, "fatal: upload-pack not permitted on this server", e);
@@ -61,20 +69,35 @@
       throw new Failure(1, "fatal: unable to check permissions ", e);
     }
 
-    final UploadPack up = new UploadPack(repo);
-    up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
+    Repository permissionAwareRepo = PermissionAwareRepositoryManager.wrap(repo, perm);
+    UploadPack up = new UploadPack(permissionAwareRepo);
+
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
     up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+    if (projectState.isAllUsers()) {
+      up.setAdvertiseRefsHook(usersSelfAdvertiseRefsHook);
+    }
+    if (extraParameters != null) {
+      up.setExtraParameters(ImmutableList.copyOf(extraParameters));
+    }
 
     List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
     allPreUploadHooks.add(
-        uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
+        uploadValidatorsFactory.create(
+            project, permissionAwareRepo, session.getRemoteAddressAsString()));
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
     for (UploadPackInitializer initializer : uploadPackInitializers) {
       initializer.init(projectState.getNameKey(), up);
     }
-    try {
+    try (TraceContext traceContext = TraceContext.open();
+        TracingHook tracingHook = new TracingHook()) {
+      RequestInfo requestInfo =
+          RequestInfo.builder(RequestInfo.RequestType.GIT_UPLOAD, user, traceContext)
+              .project(projectState.getNameKey())
+              .build();
+      requestListeners.runEach(l -> l.onRequest(requestInfo));
+      up.setProtocolV2Hook(tracingHook);
       up.upload(in, out, err);
       session.setPeerAgent(up.getPeerUserAgent());
       stats = up.getStatistics();
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 24f82a7..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 (s == PacketLineIn.END) {
+      if (PacketLineIn.isEnd(s)) {
         break;
       }
       if (!s.startsWith(argCmd)) {
diff --git a/java/com/google/gerrit/testing/AssertableExecutorService.java b/java/com/google/gerrit/testing/AssertableExecutorService.java
new file mode 100644
index 0000000..18ac2e9
--- /dev/null
+++ b/java/com/google/gerrit/testing/AssertableExecutorService.java
@@ -0,0 +1,65 @@
+// 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.testing;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.util.concurrent.ForwardingExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Forwards all calls to a direct executor making it so that the submitted {@link Runnable}s run
+ * synchronously. Holds a count of the number of tasks that were executed.
+ */
+public class AssertableExecutorService extends ForwardingExecutorService {
+
+  private final ExecutorService delegate = MoreExecutors.newDirectExecutorService();
+  private final AtomicInteger numInteractions = new AtomicInteger();
+
+  @Override
+  protected ExecutorService delegate() {
+    return delegate;
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    numInteractions.incrementAndGet();
+    return super.submit(task, result);
+  }
+
+  /** Asserts and resets the number of executions this executor observed. */
+  public void assertInteractions(int expectedNumInteractions) {
+    assertWithMessage("expectedRunnablesSubmittedOnExecutor")
+        .that(numInteractions.get())
+        .isEqualTo(expectedNumInteractions);
+    numInteractions.set(0);
+  }
+}
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index dc1c150..c610d07 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -3,19 +3,19 @@
 java_library(
     name = "gerrit-test-util",
     testonly = True,
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["AssertableExecutorService.java"],
+    ),
     visibility = ["//visibility:public"],
     exports = [
-        "//lib/easymock",
-        "//lib/powermock:powermock-api-easymock",
-        "//lib/powermock:powermock-api-support",
-        "//lib/powermock:powermock-core",
-        "//lib/powermock:powermock-module-junit4",
-        "//lib/powermock:powermock-module-junit4-common",
+        "//lib:junit",
+        "//lib/mockito",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
@@ -24,8 +24,6 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/pgm/init",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
@@ -38,16 +36,28 @@
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/log:impl-log4j",
         "//lib/log:log4j",
         "//lib/truth",
     ],
 )
+
+java_library(
+    # This can't be part of gerrit-test-util because of https://github.com/google/guava/issues/2837
+    name = "assertable-executor",
+    testonly = True,
+    srcs = ["AssertableExecutorService.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index 0fcca24..d7ae397 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -57,6 +57,7 @@
  *   public static Config firstConfig() {
  *     Config cfg = new Config();
  *     cfg.setString("gerrit", null, "testValue", "a");
+ *     return cfg;
  *   }
  * }
  *
@@ -65,6 +66,7 @@
  *   public static Config secondConfig() {
  *     Config cfg = new Config();
  *     cfg.setString("gerrit", null, "testValue", "b");
+ *     return cfg;
  *   }
  *
  *   {@literal @}Test
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index b99a32d..0178c72 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -17,11 +17,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.HashMap;
 import java.util.Map;
@@ -42,7 +40,10 @@
     if (state != null) {
       return state;
     }
-    return newState(new Account(accountId, TimeUtil.nowTs()));
+    return newState(
+        Account.builder(accountId, TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build());
   }
 
   @Override
@@ -74,10 +75,10 @@
 
   public synchronized void put(Account account) {
     AccountState state = newState(account);
-    byId.put(account.getId(), state);
+    byId.put(account.id(), state);
   }
 
   private static AccountState newState(Account account) {
-    return AccountState.forAccount(new AllUsersName(AllUsersNameProvider.DEFAULT), account);
+    return AccountState.forAccount(account);
   }
 }
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index 9a922d6..ad985b6 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -21,7 +21,7 @@
 import org.junit.runners.model.Statement;
 
 @RunWith(ConfigSuite.class)
-public class GerritServerTests extends GerritBaseTests {
+public class GerritServerTests {
   @ConfigSuite.Parameter public Config config;
 
   @ConfigSuite.Name private String configName;
diff --git a/java/com/google/gerrit/testing/GerritBaseTests.java b/java/com/google/gerrit/testing/GerritTestName.java
similarity index 68%
rename from java/com/google/gerrit/testing/GerritBaseTests.java
rename to java/com/google/gerrit/testing/GerritTestName.java
index 500d791..d287837 100644
--- a/java/com/google/gerrit/testing/GerritBaseTests.java
+++ b/java/com/google/gerrit/testing/GerritTestName.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// 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.
@@ -16,23 +16,21 @@
 
 import com.google.common.base.CharMatcher;
 import org.junit.BeforeClass;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
 import org.junit.rules.TestName;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
 
-@Ignore
-public abstract class GerritBaseTests {
-  @Rule public ExpectedException exception = ExpectedException.none();
-  @Rule public final TestName testName = new TestName();
+public class GerritTestName implements TestRule {
+  private final TestName delegate = new TestName();
 
   @BeforeClass
   public static void beforeClassTest() {
     TestLoggingActivator.configureLogging();
   }
 
-  protected String getSanitizedMethodName() {
-    String name = testName.getMethodName().toLowerCase();
+  public String getSanitizedMethodName() {
+    String name = delegate.getMethodName().toLowerCase();
     name =
         CharMatcher.inRange('a', 'z')
             .or(CharMatcher.inRange('A', 'Z'))
@@ -42,4 +40,9 @@
     name = CharMatcher.is('_').trimTrailingFrom(name);
     return name;
   }
+
+  @Override
+  public Statement apply(Statement base, Description description) {
+    return delegate.apply(base, description);
+  }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 83b9e2b..abb5955 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,10 +19,13 @@
 
 import com.google.common.base.Strings;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -61,7 +64,6 @@
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
 import com.google.gerrit.server.index.change.AllChangesIndexer;
@@ -82,6 +84,7 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -172,8 +175,8 @@
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
-    // TODO(dborowitz): Use Jimfs. The biggest blocker is that JGit does not support Path-based
-    // Configs, only FileBasedConfig.
+    // It would be nice to use Jimfs for the SitePath, but the biggest blocker is that JGit does not
+    // support Path-based Configs, only FileBasedConfig.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
@@ -218,28 +221,19 @@
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    IndexType indexType = null;
-    try {
-      indexType = cfg.getEnum("index", null, "type", IndexType.LUCENE);
-    } catch (IllegalArgumentException e) {
-      // Custom index type, caller must provide their own module.
-    }
-    if (indexType != null) {
-      switch (indexType) {
-        case LUCENE:
-          install(luceneIndexModule());
-          break;
-        case ELASTICSEARCH:
-          install(elasticIndexModule());
-          break;
-        default:
-          throw new ProvisionException("index type unsupported in tests: " + indexType);
-      }
+    IndexType indexType = new IndexType(cfg.getString("index", null, "type"));
+    // For custom index types, callers must provide their own module.
+    if (indexType.isLucene()) {
+      install(luceneIndexModule());
+    } else if (indexType.isElasticsearch()) {
+      install(elasticIndexModule());
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
     install(new DefaultProjectNameLockManager.Module());
+
+    bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
 
   /** Copy of SchemaModule with a slightly different server ID provider. */
@@ -301,11 +295,10 @@
 
   private Module indexModule(String moduleClassName) {
     try {
-      boolean slave = cfg.getBoolean("container", "slave", false);
       Class<?> clazz = Class.forName(moduleClassName);
       Method m =
           clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
-      return (Module) m.invoke(null, getSingleSchemaVersions(), 0, slave);
+      return (Module) m.invoke(null, getSingleSchemaVersions(), 0, ReplicaUtil.isReplica(cfg));
     } catch (ClassNotFoundException
         | SecurityException
         | NoSuchMethodException
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index e44d8d38..09ae115 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.inject.Inject;
@@ -103,7 +103,7 @@
   public synchronized SortedSet<Project.NameKey> list() {
     SortedSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
-      names.add(new Project.NameKey(repo.getDescription().getRepositoryName()));
+      names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
     return ImmutableSortedSet.copyOf(names);
   }
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index 05672aa..44d5cea 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.testing;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index 9cace88..21c49dd 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -23,6 +23,7 @@
 
   public static Config createFromExistingConfig(Config cfg) {
     cfg.setInt("index", null, "maxPages", 10);
+    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index d0a939d..b795c5b 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -17,14 +17,13 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.collect.Ordering;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
@@ -52,13 +51,13 @@
   }
 
   public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
-    Change.Id changeId = new Change.Id(id);
+    Change.Id changeId = Change.id(id);
     Change c =
         new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            Change.key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
             changeId,
             userId,
-            new Branch.NameKey(project, "master"),
+            BranchNameKey.create(project, "master"),
             TimeUtil.nowTs());
     incrementPatchSet(c);
     return c;
@@ -69,11 +68,12 @@
   }
 
   public static PatchSet newPatchSet(PatchSet.Id id, String revision, Account.Id userId) {
-    PatchSet ps = new PatchSet(id);
-    ps.setRevision(new RevId(revision));
-    ps.setUploader(userId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    return ps;
+    return PatchSet.builder()
+        .id(id)
+        .commitId(ObjectId.fromString(revision))
+        .uploader(userId)
+        .createdOn(TimeUtil.nowTs())
+        .build();
   }
 
   public static ChangeUpdate newUpdate(
@@ -115,11 +115,11 @@
               .author(ident)
               .committer(ident)
               .message(firstNonNull(c.getSubject(), "Test change"));
-      Ref parent = repo.exactRef(c.getDest().get());
+      Ref parent = repo.exactRef(c.getDest().branch());
       if (parent != null) {
         cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
       }
-      update.setBranch(c.getDest().get());
+      update.setBranch(c.getDest().branch());
       update.setChangeId(c.getKey().get());
       update.setCommit(tr.getRevWalk(), cb.create());
       return update;
@@ -129,7 +129,7 @@
   public static void incrementPatchSet(Change change) {
     PatchSet.Id curr = change.currentPatchSetId();
     PatchSetInfo ps =
-        new PatchSetInfo(new PatchSet.Id(change.getId(), curr != null ? curr.get() + 1 : 1));
+        new PatchSetInfo(PatchSet.id(change.getId(), curr != null ? curr.get() + 1 : 1));
     ps.setSubject("Change subject");
     change.setCurrentPatchSet(ps);
   }
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
new file mode 100644
index 0000000..b72cca7
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -0,0 +1,107 @@
+// 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.testing;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.inject.Inject;
+import java.util.Collection;
+
+/** Test helper for dealing with comments/drafts. */
+public class TestCommentHelper {
+  private final GerritApi gApi;
+
+  @Inject
+  public TestCommentHelper(GerritApi gerritApi) {
+    gApi = gerritApi;
+  }
+
+  public DraftInput newDraft(String message) {
+    return populate(new DraftInput(), "file", message);
+  }
+
+  public DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, side, line, message);
+  }
+
+  public void addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments().values().stream()
+        .flatMap(Collection::stream)
+        .collect(toList());
+  }
+
+  public static <C extends Comment> C populate(C c, String path, String message) {
+    return populate(c, path, createLineRange(), message);
+  }
+
+  private static <C extends Comment> C populate(C c, String path, Range range, String message) {
+    int line = range.startLine;
+    c.path = path;
+    c.side = Side.REVISION;
+    c.parent = null;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    c.unresolved = false;
+    if (line != 0) c.range = range;
+    return c;
+  }
+
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, Range range, String message) {
+    int line = range.startLine;
+    c.path = path;
+    c.side = side;
+    c.parent = null;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    c.unresolved = false;
+    if (line != 0) c.range = range;
+    return c;
+  }
+
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, int line, String message) {
+    return populate(c, path, side, createLineRange(line), message);
+  }
+
+  private static Range createLineRange() {
+    Range range = new Range();
+    range.startLine = 0;
+    range.startCharacter = 1;
+    range.endLine = 0;
+    range.endCharacter = 5;
+    return range;
+  }
+
+  private static Range createLineRange(int line) {
+    Range range = new Range();
+    range.startLine = line;
+    range.startCharacter = 1;
+    range.endLine = line;
+    range.endCharacter = 5;
+    return range;
+  }
+}
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index f21e3c9..b0dfef0 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -8,6 +8,7 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/truth/CacheStatsSubject.java b/java/com/google/gerrit/truth/CacheStatsSubject.java
index f1a9393..ff94334 100644
--- a/java/com/google/gerrit/truth/CacheStatsSubject.java
+++ b/java/com/google/gerrit/truth/CacheStatsSubject.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.UsedAt.Project;
 
 @UsedAt(Project.PLUGINS_ALL)
-public class CacheStatsSubject extends Subject<CacheStatsSubject, CacheStats> {
+public class CacheStatsSubject extends Subject {
   public static CacheStatsSubject assertThat(CacheStats stats) {
     return assertAbout(CacheStatsSubject::new).that(stats);
   }
@@ -39,10 +39,12 @@
         other.evictionCount());
   }
 
+  private final CacheStats stats;
   private CacheStats start = new CacheStats(0, 0, 0, 0, 0, 0);
 
   private CacheStatsSubject(FailureMetadata failureMetadata, CacheStats stats) {
     super(failureMetadata, stats);
+    this.stats = stats;
   }
 
   public CacheStatsSubject since(CacheStats start) {
@@ -52,11 +54,11 @@
 
   public void hasHitCount(int expectedHitCount) {
     isNotNull();
-    check("hitCount()").that(actual().minus(start).hitCount()).isEqualTo(expectedHitCount);
+    check("hitCount()").that(stats.minus(start).hitCount()).isEqualTo(expectedHitCount);
   }
 
   public void hasMissCount(int expectedMissCount) {
     isNotNull();
-    check("missCount()").that(actual().minus(start).missCount()).isEqualTo(expectedMissCount);
+    check("missCount()").that(stats.minus(start).missCount()).isEqualTo(expectedMissCount);
   }
 }
diff --git a/java/com/google/gerrit/truth/ConfigSubject.java b/java/com/google/gerrit/truth/ConfigSubject.java
new file mode 100644
index 0000000..dd55b71
--- /dev/null
+++ b/java/com/google/gerrit/truth/ConfigSubject.java
@@ -0,0 +1,129 @@
+// 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.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.LongSubject;
+import com.google.common.truth.MultimapSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.Config;
+
+public class ConfigSubject extends Subject {
+  public static ConfigSubject assertThat(Config config) {
+    return assertAbout(ConfigSubject::new).that(config);
+  }
+
+  private final Config config;
+
+  private ConfigSubject(FailureMetadata metadata, Config actual) {
+    super(metadata, actual);
+    this.config = actual;
+  }
+
+  public IterableSubject sections() {
+    isNotNull();
+    return check("getSections()").that(config.getSections());
+  }
+
+  public IterableSubject subsections(String section) {
+    requireNonNull(section);
+    isNotNull();
+    return check("getSubsections(%s)", section).that(config.getSubsections(section));
+  }
+
+  public MultimapSubject sectionValues(String section) {
+    requireNonNull(section);
+    return sectionValuesImpl(section, null);
+  }
+
+  public MultimapSubject subsectionValues(String section, String subsection) {
+    requireNonNull(section);
+    requireNonNull(subsection);
+    return sectionValuesImpl(section, subsection);
+  }
+
+  private MultimapSubject sectionValuesImpl(String section, @Nullable String subsection) {
+    isNotNull();
+    ImmutableListMultimap.Builder<String, String> b = ImmutableListMultimap.builder();
+    config
+        .getNames(section, subsection, true)
+        .forEach(
+            n ->
+                Arrays.stream(config.getStringList(section, subsection, n))
+                    .forEach(v -> b.put(n, v)));
+    return check("getSection(%s, %s)", section, subsection).that(b.build());
+  }
+
+  public void isEmpty() {
+    sections().isEmpty();
+  }
+
+  public StringSubject text() {
+    isNotNull();
+    return check("toText()").that(config.toText());
+  }
+
+  public IterableSubject stringValues(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getStringList(%s, %s, %s)", section, subsection, name)
+        .that(Arrays.asList(config.getStringList(section, subsection, name)));
+  }
+
+  public StringSubject stringValue(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getString(%s, %s, %s)", section, subsection, name)
+        .that(config.getString(section, subsection, name));
+  }
+
+  public IntegerSubject intValue(
+      String section, @Nullable String subsection, String name, int defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getInt(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getInt(section, subsection, name, defaultValue));
+  }
+
+  public LongSubject longValue(String section, String subsection, String name, long defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getLong(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getLong(section, subsection, name, defaultValue));
+  }
+
+  public BooleanSubject booleanValue(
+      String section, String subsection, String name, boolean defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getBoolean(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getBoolean(section, subsection, name, defaultValue));
+  }
+}
diff --git a/java/com/google/gerrit/truth/ListSubject.java b/java/com/google/gerrit/truth/ListSubject.java
index 9a839dd..9f93964 100644
--- a/java/com/google/gerrit/truth/ListSubject.java
+++ b/java/com/google/gerrit/truth/ListSubject.java
@@ -27,12 +27,13 @@
 import java.util.List;
 import java.util.function.BiFunction;
 
-public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
+public class ListSubject<S extends Subject, E> extends IterableSubject {
 
-  private final BiFunction<StandardSubjectBuilder, E, S> elementSubjectCreator;
+  private final List<E> list;
+  private final BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator;
 
-  public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
-      List<E> list, Subject.Factory<S, E> subjectFactory) {
+  public static <S extends Subject, E> ListSubject<S, E> assertThat(
+      List<E> list, Subject.Factory<? extends S, ? super E> subjectFactory) {
     return assertAbout(elements()).thatCustom(list, subjectFactory);
   }
 
@@ -43,15 +44,15 @@
   private ListSubject(
       FailureMetadata failureMetadata,
       List<E> list,
-      BiFunction<StandardSubjectBuilder, E, S> elementSubjectCreator) {
+      BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator) {
     super(failureMetadata, list);
+    this.list = list;
     this.elementSubjectCreator = elementSubjectCreator;
   }
 
   public S element(int index) {
     checkArgument(index >= 0, "index(%s) must be >= 0", index);
     isNotNull();
-    List<E> list = getActualList();
     if (index >= list.size()) {
       failWithoutActual(fact("expected to have element at index", index));
     }
@@ -61,43 +62,29 @@
   public S onlyElement() {
     isNotNull();
     hasSize(1);
-    List<E> list = getActualList();
     return elementSubjectCreator.apply(check("onlyElement()"), Iterables.getOnlyElement(list));
   }
 
   public S lastElement() {
     isNotNull();
     isNotEmpty();
-    List<E> list = getActualList();
     return elementSubjectCreator.apply(check("lastElement()"), Iterables.getLast(list));
   }
 
-  @SuppressWarnings("unchecked")
-  private List<E> getActualList() {
-    // The constructor only accepts lists. -> Casting is appropriate.
-    return (List<E>) actual();
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public ListSubject<S, E> named(String s, Object... objects) {
-    // This object is returned which is of type ListSubject. -> Casting is appropriate.
-    return (ListSubject<S, E>) super.named(s, objects);
-  }
-
   public static class ListSubjectBuilder extends CustomSubjectBuilder {
 
     ListSubjectBuilder(FailureMetadata failureMetadata) {
       super(failureMetadata);
     }
 
-    public <S extends Subject<S, E>, E> ListSubject<S, E> thatCustom(
-        List<E> list, Subject.Factory<S, E> subjectFactory) {
+    public <S extends Subject, E> ListSubject<S, E> thatCustom(
+        List<E> list, Subject.Factory<? extends S, ? super E> subjectFactory) {
       return that(list, (builder, element) -> builder.about(subjectFactory).that(element));
     }
 
-    public <S extends Subject<S, E>, E> ListSubject<S, E> that(
-        List<E> list, BiFunction<StandardSubjectBuilder, E, S> elementSubjectCreator) {
+    public <S extends Subject, E> ListSubject<S, E> that(
+        List<E> list,
+        BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator) {
       return new ListSubject<>(metadata(), list, elementSubjectCreator);
     }
   }
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
index b5fc5d0..2023765 100644
--- a/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -18,39 +18,24 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.CustomSubjectBuilder;
-import com.google.common.truth.DefaultSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.Subject;
 import java.util.Optional;
 import java.util.function.BiFunction;
-import java.util.function.Function;
 
-public class OptionalSubject<S extends Subject<S, ? super T>, T>
-    extends Subject<OptionalSubject<S, T>, Optional<T>> {
+public class OptionalSubject<S extends Subject, T> extends Subject {
 
+  private final Optional<T> optional;
   private final BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator;
 
-  // TODO(aliceks): Remove when all relevant usages are adapted to new check()/factory approach.
-  public static <S extends Subject<S, T>, T> OptionalSubject<S, T> assertThat(
-      Optional<T> optional, Function<? super T, ? extends S> elementAssertThatFunction) {
-    Subject.Factory<S, T> valueSubjectFactory =
-        (metadata, value) -> elementAssertThatFunction.apply(value);
-    return assertThat(optional, valueSubjectFactory);
-  }
-
-  public static <S extends Subject<S, T>, T> OptionalSubject<S, T> assertThat(
-      Optional<T> optional, Subject.Factory<S, T> valueSubjectFactory) {
+  public static <S extends Subject, T> OptionalSubject<S, T> assertThat(
+      Optional<T> optional, Subject.Factory<? extends S, ? super T> valueSubjectFactory) {
     return assertAbout(optionals()).thatCustom(optional, valueSubjectFactory);
   }
 
-  public static OptionalSubject<DefaultSubject, ?> assertThat(Optional<?> optional) {
-    // Unfortunately, we need to cast to DefaultSubject as StandardSubjectBuilder#that
-    // only returns Subject<DefaultSubject, Object>. There shouldn't be a way
-    // for that method not to return a DefaultSubject because the generic type
-    // definitions of a Subject are quite strict.
-    return assertAbout(optionals())
-        .that(optional, (builder, value) -> (DefaultSubject) builder.that(value));
+  public static OptionalSubject<Subject, ?> assertThat(Optional<?> optional) {
+    return assertAbout(optionals()).that(optional);
   }
 
   public static CustomSubjectBuilder.Factory<OptionalSubjectBuilder> optionals() {
@@ -62,12 +47,12 @@
       Optional<T> optional,
       BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator) {
     super(failureMetadata, optional);
+    this.optional = optional;
     this.valueSubjectCreator = valueSubjectCreator;
   }
 
   public void isPresent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (!optional.isPresent()) {
       failWithoutActual(fact("expected to have", "value"));
     }
@@ -75,7 +60,6 @@
 
   public void isAbsent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (optional.isPresent()) {
       failWithoutActual(fact("expected not to have", "value"));
     }
@@ -88,7 +72,6 @@
   public S value() {
     isNotNull();
     isPresent();
-    Optional<T> optional = actual();
     return valueSubjectCreator.apply(check("value()"), optional.get());
   }
 
@@ -98,16 +81,16 @@
       super(failureMetadata);
     }
 
-    public <S extends Subject<S, T>, T> OptionalSubject<S, T> thatCustom(
-        Optional<T> optional, Subject.Factory<S, T> valueSubjectFactory) {
+    public <S extends Subject, T> OptionalSubject<S, T> thatCustom(
+        Optional<T> optional, Subject.Factory<? extends S, ? super T> valueSubjectFactory) {
       return that(optional, (builder, value) -> builder.about(valueSubjectFactory).that(value));
     }
 
-    public OptionalSubject<DefaultSubject, ?> that(Optional<?> optional) {
-      return that(optional, (builder, value) -> (DefaultSubject) builder.that(value));
+    public OptionalSubject<Subject, ?> that(Optional<?> optional) {
+      return that(optional, StandardSubjectBuilder::that);
     }
 
-    public <S extends Subject<S, ? super T>, T> OptionalSubject<S, T> that(
+    public <S extends Subject, T> OptionalSubject<S, T> that(
         Optional<T> optional,
         BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator) {
       return new OptionalSubject<>(metadata(), optional, valueSubjectCreator);
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 1c16133..162f324 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -35,13 +35,14 @@
 package com.google.gerrit.util.cli;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.util.cli.Localizable.localizable;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -313,23 +314,13 @@
     parser.parseArgument(tmp.toArray(new String[tmp.size()]));
   }
 
-  public void parseOptionMap(Map<String, String[]> parameters) throws CmdLineException {
-    ListMultimap<String, String> map = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Map.Entry<String, String[]> ent : parameters.entrySet()) {
-      for (String val : ent.getValue()) {
-        map.put(ent.getKey(), val);
-      }
-    }
-    parseOptionMap(map);
-  }
-
   public void parseOptionMap(ListMultimap<String, String> params) throws CmdLineException {
     logger.atFinest().log("Command-line parameters: %s", params.keySet());
     List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
     for (String key : params.keySet()) {
       String name = makeOption(key);
 
-      if (isBoolean(name)) {
+      if (isBooleanOption(name)) {
         boolean on = false;
         for (String value : params.get(key)) {
           on = toBoolean(key, value);
@@ -347,10 +338,6 @@
     parser.parseArgument(tmp.toArray(new String[tmp.size()]));
   }
 
-  public boolean isBoolean(String name) {
-    return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
-  }
-
   public void parseWithPrefix(String prefix, Object bean) {
     parser.parseWithPrefix(prefix, bean);
   }
@@ -359,6 +346,10 @@
     parser.addOptionsWithMetRequirements();
   }
 
+  private boolean isBooleanOption(String name) {
+    return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -422,7 +413,8 @@
   private static Option newPrefixedOption(String prefix, Option o) {
     requireNonNull(prefix);
     checkArgument(o.name().startsWith("-"), "Option name must start with '-': %s", o);
-    String[] aliases = Arrays.stream(o.aliases()).map(prefix::concat).toArray(String[]::new);
+    ImmutableList<String> aliases =
+        Arrays.stream(o.aliases()).map(prefix::concat).collect(toImmutableList());
     return OptionUtil.newOption(
         prefix + o.name(),
         aliases,
@@ -432,8 +424,8 @@
         false,
         o.hidden(),
         o.handler(),
-        o.depends(),
-        new String[0]);
+        ImmutableList.copyOf(o.depends()),
+        ImmutableList.of());
   }
 
   public class MyParser extends org.kohsuke.args4j.CmdLineParser {
@@ -625,15 +617,15 @@
     private Option newHelpOption() {
       return OptionUtil.newOption(
           "--help",
-          new String[] {"-h"},
+          ImmutableList.of("-h"),
           "display this help text",
           "",
           false,
           false,
           false,
           BooleanOptionHandler.class,
-          new String[0],
-          new String[0]);
+          ImmutableList.of(),
+          ImmutableList.of());
     }
 
     private boolean isHandlerSpecified(OptionDef option) {
diff --git a/java/com/google/gerrit/util/cli/OptionUtil.java b/java/com/google/gerrit/util/cli/OptionUtil.java
index 1125a0d..68cd717 100644
--- a/java/com/google/gerrit/util/cli/OptionUtil.java
+++ b/java/com/google/gerrit/util/cli/OptionUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.util.cli;
 
 import com.google.auto.value.AutoAnnotation;
+import com.google.common.collect.ImmutableList;
 import org.kohsuke.args4j.Option;
 import org.kohsuke.args4j.spi.OptionHandler;
 
@@ -24,15 +25,15 @@
   @SuppressWarnings("rawtypes")
   public static Option newOption(
       String name,
-      String[] aliases,
+      ImmutableList<String> aliases,
       String usage,
       String metaVar,
       boolean required,
       boolean help,
       boolean hidden,
       Class<? extends OptionHandler> handler,
-      String[] depends,
-      String[] forbids) {
+      ImmutableList<String> depends,
+      ImmutableList<String> forbids) {
     return new AutoAnnotation_OptionUtil_newOption(
         name, aliases, usage, metaVar, required, help, hidden, handler, depends, forbids);
   }
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index 5ecb7a1..fbd1379 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -4,5 +4,5 @@
     name = "http",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
+    deps = ["//lib:servlet-api"],
 )
diff --git a/java/com/google/gwtorm/BUILD b/java/com/google/gwtorm/BUILD
deleted file mode 100644
index baf7a8cb..0000000
--- a/java/com/google/gwtorm/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "gwtorm",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-)
diff --git a/java/com/google/gwtorm/client/CompoundKey.java b/java/com/google/gwtorm/client/CompoundKey.java
deleted file mode 100644
index 1c66d18..0000000
--- a/java/com/google/gwtorm/client/CompoundKey.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// 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.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type composed of other keys.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class CompoundKey<P extends Key<?>> implements Key<P>, Serializable {
-  /** @return the member key components, minus the parent key. */
-  public abstract Key<?>[] members();
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = 0;
-    if (getParentKey() != null) {
-      hc = getParentKey().hashCode();
-    }
-    for (final Key<?> k : members()) {
-      hc *= 31;
-      hc += k.hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final CompoundKey<P> q = cast(b);
-    if (getParentKey() != null && !getParentKey().equals(q.getParentKey())) {
-      return false;
-    }
-
-    final Key<?>[] aMembers = members();
-    final Key<?>[] bMembers = q.members();
-    if (aMembers.length != bMembers.length) {
-      return false;
-    }
-    for (int i = 0; i < aMembers.length; i++) {
-      if (!aMembers[i].equals(bMembers[i])) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    boolean first = true;
-    if (getParentKey() != null) {
-      r.append(KeyUtil.encode(getParentKey().toString()));
-      first = false;
-    }
-    for (final Key<?> k : members()) {
-      if (!first) {
-        r.append(',');
-      }
-      r.append(KeyUtil.encode(k.toString()));
-      first = false;
-    }
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    final String[] parts = in.split(",");
-    int p = 0;
-    if (getParentKey() != null) {
-      getParentKey().fromString(KeyUtil.decode(parts[p++]));
-    }
-    for (final Key<?> k : members()) {
-      k.fromString(KeyUtil.decode(parts[p++]));
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> CompoundKey<A> cast(final Object b) {
-    return (CompoundKey<A>) b;
-  }
-}
diff --git a/java/com/google/gwtorm/client/IntKey.java b/java/com/google/gwtorm/client/IntKey.java
deleted file mode 100644
index 08c90e0..0000000
--- a/java/com/google/gwtorm/client/IntKey.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// 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.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type using a single integer value.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class IntKey<P extends Key<?>> implements Key<P>, Serializable {
-  /** @return id of the entity instance. */
-  public abstract int get();
-
-  /** @param newValue the new value of this key. */
-  protected abstract void set(int newValue);
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = get();
-    if (getParentKey() != null) {
-      hc *= 31;
-      hc += getParentKey().hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final IntKey<P> q = cast(b);
-    return get() == q.get() && KeyUtil.eq(getParentKey(), q.getParentKey());
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    if (getParentKey() != null) {
-      r.append(getParentKey().toString());
-      r.append(',');
-    }
-    r.append(get());
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    set(Integer.parseInt(KeyUtil.parseFromString(getParentKey(), in)));
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> IntKey<A> cast(final Object b) {
-    return (IntKey<A>) b;
-  }
-}
diff --git a/java/com/google/gwtorm/client/Key.java b/java/com/google/gwtorm/client/Key.java
deleted file mode 100644
index 69a2248..0000000
--- a/java/com/google/gwtorm/client/Key.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// 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.gwtorm.client;
-
-/**
- * Generic type for an entity key.
- *
- * <p>Although not required, entities should make their primary key type implement this interface,
- * permitting traversal up through the containment hierarchy of the entity keys.
- *
- * @param <P> type of the parent key. If no parent, use {@link Key} itself.
- */
-public interface Key<P extends Key<?>> {
-  /**
-   * Get the parent key instance.
-   *
-   * @return the parent key; null if this entity key is a root-level key.
-   */
-  public P getParentKey();
-
-  @Override
-  public int hashCode();
-
-  @Override
-  public boolean equals(Object o);
-
-  /** @return the key, encoded in a string format . */
-  @Override
-  public String toString();
-
-  /** Reset this key instance to represent the data in the supplied string. */
-  public void fromString(String in);
-}
diff --git a/java/com/google/gwtorm/client/KeyUtil.java b/java/com/google/gwtorm/client/KeyUtil.java
deleted file mode 100644
index e236d37..0000000
--- a/java/com/google/gwtorm/client/KeyUtil.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// 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.gwtorm.client;
-
-/** Common utility functions for {@link Key} implementors. */
-public class KeyUtil {
-  private static Encoder ENCODER_IMPL = new StandardKeyEncoder();
-
-  /**
-   * Determine if two keys are equal, supporting null references.
-   *
-   * @param <T> type of the key entity.
-   * @param a first key to test; may be null.
-   * @param b second key to test; may be null.
-   * @return true if both <code>a</code> and <code>b</code> are null, or if both are not-null and
-   *     <code>a.equals(b)</code> is true. Otherwise false.
-   */
-  public static <T extends Key<?>> boolean eq(final T a, final T b) {
-    if (a == b) {
-      return true;
-    }
-    if (a == null || b == null) {
-      return false;
-    }
-    return a.equals(b);
-  }
-
-  /**
-   * Encode a string to be safe for use within a URL like string.
-   *
-   * <p>The returned encoded string has URL component characters escaped with hex escapes (e.g. ' '
-   * is '+' and '%' is '%25'). The special character '/' is left literal. The comma character (',')
-   * is always encoded, permitting multiple encoded string values to be joined together safely.
-   *
-   * @param e the string to encode, must not be null.
-   * @return the encoded string.
-   */
-  public static String encode(final String e) {
-    return ENCODER_IMPL.encode(e);
-  }
-
-  /**
-   * Decode a string previously encoded by {@link #encode(String)}.
-   *
-   * @param e the string to decode, must not be null.
-   * @return the decoded string.
-   */
-  public static String decode(final String e) {
-    return ENCODER_IMPL.decode(e);
-  }
-
-  /**
-   * Split a string along the last comma and parse into the parent.
-   *
-   * @param parent parent key; <code>parent.fromString(in[0..comma])</code>.
-   * @param in the input string.
-   * @return text (if any) after the last comma in the input.
-   */
-  public static String parseFromString(final Key<?> parent, final String in) {
-    final int comma = in.lastIndexOf(',');
-    if (comma < 0 && parent == null) {
-      return decode(in);
-    }
-    if (comma < 0 && parent != null) {
-      throw new IllegalArgumentException("Not enough components: " + in);
-    }
-    assert (parent != null);
-    parent.fromString(in.substring(0, comma));
-    return decode(in.substring(comma + 1));
-  }
-
-  public abstract static class Encoder {
-    public abstract String encode(String e);
-
-    public abstract String decode(String e);
-  }
-
-  private KeyUtil() {}
-}
diff --git a/java/com/google/gwtorm/client/StringKey.java b/java/com/google/gwtorm/client/StringKey.java
deleted file mode 100644
index e56661f..0000000
--- a/java/com/google/gwtorm/client/StringKey.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// 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.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type using a single string value.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class StringKey<P extends Key<?>>
-    implements Key<P>, Serializable, Comparable<StringKey<?>> {
-  /** @return name of the entity instance. */
-  public abstract String get();
-
-  /** @param newValue the new value of this key. */
-  protected abstract void set(String newValue);
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = get() != null ? get().hashCode() : 0;
-    if (getParentKey() != null) {
-      hc *= 31;
-      hc += getParentKey().hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || get() == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final StringKey<P> q = cast(b);
-    return get().equals(q.get()) && KeyUtil.eq(getParentKey(), q.getParentKey());
-  }
-
-  @Override
-  public int compareTo(final StringKey<?> other) {
-    return get().compareTo(other.get());
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    if (getParentKey() != null) {
-      r.append(getParentKey().toString());
-      r.append(',');
-    }
-    r.append(KeyUtil.encode(get()));
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    set(KeyUtil.parseFromString(getParentKey(), in));
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> StringKey<A> cast(final Object b) {
-    return (StringKey<A>) b;
-  }
-}
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index 1bfc95c..bd8cf1a 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.rules.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index d7e2306..7dbf751 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -6,12 +6,11 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//lib:jgit",
         "//lib/flogger:api",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
         "@guava//jar",
     ],
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 693c89e..90a2cbf 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -4,7 +4,7 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
@@ -38,16 +38,15 @@
     LabelTypes types = cd.getLabelTypes();
 
     for (PatchSetApproval a : cd.currentApprovals()) {
-      LabelType t = types.byLabel(a.getLabelId());
+      LabelType t = types.byLabel(a.labelId());
       if (t == null) {
         continue;
       }
 
       StructureTerm labelTerm =
-          new StructureTerm(
-              sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
+          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
 
-      StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
+      StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
 
       listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
     }
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 0a7bb74..4501169 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -34,9 +34,9 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Branch.NameKey name = StoredValues.getChange(engine).getDest();
+    BranchNameKey name = StoredValues.getChange(engine).getDest();
 
-    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
+    if (!a1.unify(SymbolTerm.create(name.branch()), engine.trail)) {
       return engine.fail();
     }
     return cont;
diff --git a/java/gerrit/PRED_change_owner_1.java b/java/gerrit/PRED_change_owner_1.java
index 937b761..d42c0e1 100644
--- a/java/gerrit/PRED_change_owner_1.java
+++ b/java/gerrit/PRED_change_owner_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/java/gerrit/PRED_change_project_1.java b/java/gerrit/PRED_change_project_1.java
index 28e637a..a973e1c 100644
--- a/java/gerrit/PRED_change_project_1.java
+++ b/java/gerrit/PRED_change_project_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_change_topic_1.java b/java/gerrit/PRED_change_topic_1.java
index 564878f..11d737a 100644
--- a/java/gerrit/PRED_change_topic_1.java
+++ b/java/gerrit/PRED_change_topic_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index d2634ea..6e971fc 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 6ca5338..12e7086 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.Text;
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index c1666d8..286bc2c 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 029b84a..681d86c 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -15,8 +15,8 @@
 package gerrit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -50,7 +50,7 @@
       return engine.fail();
     }
 
-    Account.Id uploaderId = patchSet.getUploader();
+    Account.Id uploaderId = patchSet.uploader();
 
     if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
       return engine.fail();
diff --git a/javatests/com/google/gerrit/acceptance/BUILD b/javatests/com/google/gerrit/acceptance/BUILD
index 54b3626..75c90f2 100644
--- a/javatests/com/google/gerrit/acceptance/BUILD
+++ b/javatests/com/google/gerrit/acceptance/BUILD
@@ -7,8 +7,9 @@
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
index 69fdc7e..3d17de0 100644
--- a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
+++ b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.File;
 import java.nio.file.Files;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
 
-public class MergeableFileBasedConfigTest extends GerritBaseTests {
+public class MergeableFileBasedConfigTest {
   @Test
   public void mergeNull() throws Exception {
     MergeableFileBasedConfig cfg = newConfig();
@@ -112,7 +112,7 @@
   }
 
   private void assertConfig(MergeableFileBasedConfig cfg, String expected) throws Exception {
-    assertThat(cfg.toText()).isEqualTo(expected);
+    assertThat(cfg).text().isEqualTo(expected);
     cfg.save();
     assertThat(new String(Files.readAllBytes(cfg.getFile().toPath()), UTF_8)).isEqualTo(expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 53d8ef8..1a09aa1 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupIncludeCache;
@@ -31,12 +35,10 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -50,7 +52,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProjectResetterTest extends GerritBaseTests {
+public class ProjectResetterTest {
   private InMemoryRepositoryManager repoManager;
   private Project.NameKey project;
   private Repository repo;
@@ -58,7 +60,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("foo");
+    project = Project.nameKey("foo");
     repo = repoManager.createRepository(project);
   }
 
@@ -135,7 +137,7 @@
 
   @Test
   public void onlyResetMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1 = createRef("refs/foo/test");
@@ -170,7 +172,7 @@
 
   @Test
   public void onlyDeleteNewlyCreatedMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1;
@@ -216,14 +218,11 @@
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsReset() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
     Ref metaConfig = createRef(repo2, RefNames.REFS_CONFIG);
 
-    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
-    projectCache.evict(project2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(projectCache);
+    ProjectCache projectCache = mock(ProjectCache.class);
 
     Ref nonMetaConfig = createRef("refs/heads/master");
 
@@ -234,18 +233,15 @@
       updateRef(repo2, metaConfig);
     }
 
-    EasyMock.verify(projectCache);
+    verify(projectCache, only()).evict(project2);
   }
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsDeleted() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
-    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
-    projectCache.evict(project2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(projectCache);
+    ProjectCache projectCache = mock(ProjectCache.class);
 
     try (ProjectResetter resetProject =
         builder(null, null, null, null, null, null, projectCache)
@@ -254,28 +250,21 @@
       createRef(repo2, RefNames.REFS_CONFIG);
     }
 
-    EasyMock.verify(projectCache);
+    verify(projectCache, only()).evict(project2);
   }
 
   @Test
   public void accountEvictionIfUserBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(2)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -284,63 +273,47 @@
       updateRef(allUsersRepo, userBranch);
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache, only()).evict(accountId);
+    verify(accountIndexer, only()).index(accountId);
   }
 
   @Test
   public void accountEvictionIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       // Non-user branch because it's not in All-Users.
-      createRef(RefNames.refsUsers(new Account.Id(2)));
+      createRef(RefNames.refsUsers(Account.id(2)));
 
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache, only()).evict(accountId);
+    verify(accountIndexer, only()).index(accountId);
   }
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref externalIds = createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    accountCache.evict(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    accountIndexer.index(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -350,34 +323,27 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache).evict(accountId);
+    verify(accountCache).evict(accountId2);
+    verify(accountIndexer).index(accountId);
+    verify(accountIndexer).index(accountId2);
+    verifyNoMoreInteractions(accountCache, accountIndexer);
   }
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    accountCache.evict(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    accountIndexer.index(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -387,19 +353,20 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache).evict(accountId);
+    verify(accountCache).evict(accountId2);
+    verify(accountIndexer).index(accountId);
+    verify(accountIndexer).index(accountId2);
+    verifyNoMoreInteractions(accountCache, accountIndexer);
   }
 
   @Test
   public void accountEvictionFromAccountCreatorIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    AccountCreator accountCreator = EasyMock.createNiceMock(AccountCreator.class);
-    accountCreator.evict(ImmutableSet.of(accountId));
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCreator);
+    AccountCreator accountCreator = mock(AccountCreator.class);
 
     try (ProjectResetter resetProject =
         builder(accountCreator, null, null, null, null, null, null)
@@ -407,29 +374,20 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
 
-    EasyMock.verify(accountCreator);
+    verify(accountCreator, only()).evict(ImmutableSet.of(accountId));
   }
 
   @Test
   public void groupEviction() throws Exception {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("abcd1");
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("abcd2");
-    AccountGroup.UUID uuid3 = new AccountGroup.UUID("abcd3");
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("abcd1");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("abcd2");
+    AccountGroup.UUID uuid3 = AccountGroup.uuid("abcd3");
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    GroupCache cache = EasyMock.createNiceMock(GroupCache.class);
-    GroupIndexer indexer = EasyMock.createNiceMock(GroupIndexer.class);
-    GroupIncludeCache includeCache = EasyMock.createNiceMock(GroupIncludeCache.class);
-    cache.evict(uuid2);
-    indexer.index(uuid2);
-    includeCache.evictParentGroupsOf(uuid2);
-    cache.evict(uuid3);
-    indexer.index(uuid3);
-    includeCache.evictParentGroupsOf(uuid3);
-    EasyMock.expectLastCall();
-
-    EasyMock.replay(cache, indexer);
+    GroupCache cache = mock(GroupCache.class);
+    GroupIndexer indexer = mock(GroupIndexer.class);
+    GroupIncludeCache includeCache = mock(GroupIncludeCache.class);
 
     createRef(allUsersRepo, RefNames.refsGroups(uuid1));
     Ref ref2 = createRef(allUsersRepo, RefNames.refsGroups(uuid2));
@@ -440,7 +398,13 @@
       createRef(allUsersRepo, RefNames.refsGroups(uuid3));
     }
 
-    EasyMock.verify(cache, indexer);
+    verify(cache).evict(uuid2);
+    verify(indexer).index(uuid2);
+    verify(includeCache).evictParentGroupsOf(uuid2);
+    verify(cache).evict(uuid3);
+    verify(indexer).index(uuid3);
+    verify(includeCache).evictParentGroupsOf(uuid3);
+    verifyNoMoreInteractions(cache, indexer, includeCache);
   }
 
   private Ref createRef(String ref) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index bf387fd..448629c 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -16,21 +16,19 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.UniversalGroupBackend;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.inject.Inject;
 import org.junit.Test;
 
 public class TestGroupBackendTest extends AbstractDaemonTest {
-  @Inject private DynamicSet<GroupBackend> groupBackends;
   @Inject private UniversalGroupBackend universalGroupBackend;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private final TestGroupBackend testGroupBackend = new TestGroupBackend();
-  private final AccountGroup.UUID testUUID = new AccountGroup.UUID("testbackend:test");
+  private final AccountGroup.UUID testUUID = AccountGroup.uuid("testbackend:test");
 
   @Test
   public void handlesTestGroup() throws Exception {
@@ -39,17 +37,14 @@
 
   @Test
   public void universalGroupBackendHandlesTestGroup() throws Exception {
-    RegistrationHandle registrationHandle = groupBackends.add("gerrit", testGroupBackend);
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(testGroupBackend)) {
       assertThat(universalGroupBackend.handles(testUUID)).isTrue();
-    } finally {
-      registrationHandle.remove();
     }
   }
 
   @Test
   public void doesNotHandleLDAP() throws Exception {
-    assertThat(testGroupBackend.handles(new AccountGroup.UUID("ldap:1234"))).isFalse();
+    assertThat(testGroupBackend.handles(AccountGroup.uuid("ldap:1234"))).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/annotation/BUILD b/javatests/com/google/gerrit/acceptance/annotation/BUILD
index 5476bb6..8dd99c9 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/BUILD
+++ b/javatests/com/google/gerrit/acceptance/annotation/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*.java"]),
     group = "annotation",
     labels = ["annotation"],
+    deps = ["//java/com/google/gerrit/server/util/time"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
new file mode 100644
index 0000000..ecfe3f5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+public class UseClockStepTest extends AbstractDaemonTest {
+  @Test
+  @UseClockStep
+  public void useClockStepWithDefaults() {
+    long firstTimestamp = TimeUtil.nowMs();
+    long secondTimestamp = TimeUtil.nowMs();
+    assertThat(secondTimestamp - firstTimestamp).isEqualTo(1000);
+  }
+
+  @Test
+  @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+  public void useClockStepWithTimeUnit() {
+    long firstTimestamp = TimeUtil.nowMs();
+    long secondTimestamp = TimeUtil.nowMs();
+    assertThat(secondTimestamp - firstTimestamp).isEqualTo(60 * 1000);
+  }
+
+  @Test
+  @UseClockStep(clockStep = 5)
+  public void useClockStepWithClockStep() {
+    long firstTimestamp = TimeUtil.nowMs();
+    long secondTimestamp = TimeUtil.nowMs();
+    assertThat(secondTimestamp - firstTimestamp).isEqualTo(5 * 1000);
+  }
+
+  @Test
+  @UseClockStep(startAtEpoch = true)
+  public void useClockStepWithStartAtEpoch() {
+    assertThat(TimeUtil.nowTs()).isEqualTo(Timestamp.from(Instant.EPOCH));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseSystemTimeTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseSystemTimeTest.java
new file mode 100644
index 0000000..7480200
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseSystemTimeTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseSystemTime;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.junit.Test;
+
+@UseClockStep
+public class UseSystemTimeTest extends AbstractDaemonTest {
+  @Test
+  @UseSystemTime
+  public void useSystemTimeAlthoughClassIsAnnotatedWithUseClockStep() {
+    long firstTimestamp = TimeUtil.nowMs();
+    long secondTimestamp = TimeUtil.nowMs();
+    assertThat(secondTimestamp - firstTimestamp).isLessThan(1000);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseTimezoneTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseTimezoneTest.java
new file mode 100644
index 0000000..abf5eda
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseTimezoneTest.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseTimezone;
+import org.junit.Test;
+
+public class UseTimezoneTest extends AbstractDaemonTest {
+  @Test
+  @UseTimezone(timezone = "US/Eastern")
+  public void usEastern() {
+    assertThat(System.getProperty("user.timezone")).isEqualTo("US/Eastern");
+  }
+
+  @Test
+  @UseTimezone(timezone = "UTC")
+  public void utc() {
+    assertThat(System.getProperty("user.timezone")).isEqualTo("UTC");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index ccdf34e..ac00782 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -17,10 +17,14 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.gpg.testing.TestKeys.allValidKeys;
@@ -33,9 +37,11 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -48,14 +54,17 @@
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -68,6 +77,12 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -89,10 +104,8 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -104,19 +117,11 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
@@ -126,17 +131,17 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
@@ -145,7 +150,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -160,6 +164,7 @@
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -180,7 +185,6 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 public class AccountIT extends AbstractDaemonTest {
@@ -198,8 +202,6 @@
 
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountIndexer accountIndexer;
-  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
-  @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
   @Inject private ExternalIds externalIds;
   @Inject private GitReferenceUpdated gitReferenceUpdated;
@@ -212,51 +214,18 @@
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject protected Emails emails;
 
   @Inject
   @Named("accounts")
-  private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
+  private LoadingCache<Account.Id, AccountState> accountsCache;
 
   @Inject private AccountOperations accountOperations;
 
-  @Inject
-  private DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners;
-
   @Inject protected GroupOperations groupOperations;
 
-  private AccountIndexedCounter accountIndexedCounter;
-  private RegistrationHandle accountIndexEventCounterHandle;
-  private RefUpdateCounter refUpdateCounter;
-  private RegistrationHandle refUpdateCounterHandle;
-
-  @Before
-  public void addAccountIndexEventCounter() {
-    accountIndexedCounter = new AccountIndexedCounter();
-    accountIndexEventCounterHandle = accountIndexedListeners.add("gerrit", accountIndexedCounter);
-  }
-
-  @After
-  public void removeAccountIndexEventCounter() {
-    if (accountIndexEventCounterHandle != null) {
-      accountIndexEventCounterHandle.remove();
-    }
-  }
-
-  @Before
-  public void addRefUpdateCounter() {
-    refUpdateCounter = new RefUpdateCounter();
-    refUpdateCounterHandle = refUpdateListeners.add("gerrit", refUpdateCounter);
-  }
-
-  @After
-  public void removeRefUpdateCounter() {
-    if (refUpdateCounterHandle != null) {
-      refUpdateCounterHandle.remove();
-    }
-  }
-
   @After
   public void clearPublicKeyStore() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -305,11 +274,14 @@
 
   @Test
   public void createByAccountCreator() throws Exception {
-    Account.Id accountId = createByAccountCreator(1);
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
-        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
+    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
+      Account.Id accountId = createByAccountCreator(1);
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
+    }
   }
 
   @Test
@@ -328,42 +300,54 @@
   }
 
   private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
-    String name = "foo";
-    TestAccount foo = accountCreator.create(name);
-    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-    assertThat(info.username).isEqualTo(name);
-    assertThat(info.name).isEqualTo(name);
-    accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
-    assertUserBranch(foo.id(), name, null);
-    return foo.id();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String name = "foo";
+      TestAccount foo = accountCreator.create(name);
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      assertThat(info.username).isEqualTo(name);
+      assertThat(info.name).isEqualTo(name);
+      accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
+      assertUserBranch(foo.id(), name, null);
+      return foo.id();
+    }
   }
 
   @Test
   public void createAnonymousCowardByAccountCreator() throws Exception {
-    TestAccount anonymousCoward = accountCreator.create();
-    accountIndexedCounter.assertReindexOf(anonymousCoward);
-    assertUserBranchWithoutAccountConfig(anonymousCoward.id());
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount anonymousCoward = accountCreator.create();
+      accountIndexedCounter.assertReindexOf(anonymousCoward);
+      assertUserBranchWithoutAccountConfig(anonymousCoward.id());
+    }
   }
 
   @Test
   public void create() throws Exception {
-    AccountInput input = new AccountInput();
-    input.username = "foo";
-    input.name = "Foo";
-    input.email = "foo@example.com";
-    AccountInfo accountInfo = gApi.accounts().create(input).get();
-    assertThat(accountInfo._accountId).isNotNull();
-    assertThat(accountInfo.username).isEqualTo(input.username);
-    assertThat(accountInfo.name).isEqualTo(input.name);
-    assertThat(accountInfo.email).isEqualTo(input.email);
-    assertThat(accountInfo.status).isNull();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      AccountInput input = new AccountInput();
+      input.username = "foo";
+      input.name = "Foo";
+      input.email = "foo@example.com";
+      AccountInfo accountInfo = gApi.accounts().create(input).get();
+      assertThat(accountInfo._accountId).isNotNull();
+      assertThat(accountInfo.username).isEqualTo(input.username);
+      assertThat(accountInfo.name).isEqualTo(input.name);
+      assertThat(accountInfo.email).isEqualTo(input.email);
+      assertThat(accountInfo.status).isNull();
 
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
-    accountIndexedCounter.assertReindexOf(accountId, 1);
-    assertThat(externalIds.byAccount(accountId))
-        .containsExactly(
-            ExternalId.createUsername(input.username, accountId, null),
-            ExternalId.createEmail(accountId, input.email));
+      Account.Id accountId = Account.id(accountInfo._accountId);
+      accountIndexedCounter.assertReindexOf(accountId, 1);
+      assertThat(externalIds.byAccount(accountId))
+          .containsExactly(
+              ExternalId.createUsername(input.username, accountId, null),
+              ExternalId.createEmail(accountId, input.email));
+    }
   }
 
   @Test
@@ -371,9 +355,11 @@
     AccountInput input = new AccountInput();
     input.username = admin.username();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("username '" + admin.username() + "' already exists");
-    gApi.accounts().create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("username '" + admin.username() + "' already exists");
   }
 
   @Test
@@ -382,15 +368,15 @@
     input.username = "foo";
     input.email = admin.email();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("email '" + admin.email() + "' already exists");
-    gApi.accounts().create(input);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown).hasMessageThat().contains("email '" + admin.email() + "' already exists");
   }
 
   @Test
   public void commitMessageOnAccountUpdates() throws Exception {
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     au.insert("Create Test Account", accountId, u -> {});
     assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
 
@@ -409,39 +395,37 @@
   }
 
   @Test
+  @UseClockStep
   public void createAtomically() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    try {
-      Account.Id accountId = new Account.Id(seq.nextAccountId());
-      String fullName = "Foo";
-      ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
-      AccountState accountState =
-          accountsUpdateProvider
-              .get()
-              .insert(
-                  "Create Account Atomically",
-                  accountId,
-                  u -> u.setFullName(fullName).addExternalId(extId));
-      assertThat(accountState.getAccount().getFullName()).isEqualTo(fullName);
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    String fullName = "Foo";
+    ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
+    AccountState accountState =
+        accountsUpdateProvider
+            .get()
+            .insert(
+                "Create Account Atomically",
+                accountId,
+                u -> u.setFullName(fullName).addExternalId(extId));
+    assertThat(accountState.account().fullName()).isEqualTo(fullName);
 
-      AccountInfo info = gApi.accounts().id(accountId.get()).get();
-      assertThat(info.name).isEqualTo(fullName);
+    AccountInfo info = gApi.accounts().id(accountId.get()).get();
+    assertThat(info.name).isEqualTo(fullName);
 
-      List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
-      assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
+    List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
+    assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
 
-      RevCommit commitUserBranch = getRemoteHead(allUsers, RefNames.refsUsers(accountId));
-      RevCommit commitRefsMetaExternalIds = getRemoteHead(allUsers, RefNames.REFS_EXTERNAL_IDS);
-      assertThat(commitUserBranch.getCommitTime())
-          .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
-    } finally {
-      TestTimeUtil.useSystemTime();
-    }
+    RevCommit commitUserBranch =
+        projectOperations.project(allUsers).getHead(RefNames.refsUsers(accountId));
+    RevCommit commitRefsMetaExternalIds =
+        projectOperations.project(allUsers).getHead(RefNames.REFS_EXTERNAL_IDS);
+    assertThat(commitUserBranch.getCommitTime())
+        .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
   }
 
   @Test
   public void updateNonExistingAccount() throws Exception {
-    Account.Id nonExistingAccountId = new Account.Id(999999);
+    Account.Id nonExistingAccountId = Account.id(999999);
     AtomicBoolean consumerCalled = new AtomicBoolean();
     Optional<AccountState> accountState =
         accountsUpdateProvider
@@ -463,9 +447,9 @@
             .get()
             .update("Set status", anonymousCoward.id(), u -> u.setStatus(status));
     assertThat(accountState).isPresent();
-    Account account = accountState.get().getAccount();
-    assertThat(account.getFullName()).isNull();
-    assertThat(account.getStatus()).isEqualTo(status);
+    Account account = accountState.get().account();
+    assertThat(account.fullName()).isNull();
+    assertThat(account.status()).isEqualTo(status);
     assertUserBranch(anonymousCoward.id(), null, status);
   }
 
@@ -482,7 +466,7 @@
       assertThat(ref).isNotNull();
       RevCommit c = rw.parseCommit(ref.getObjectId());
       long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).getRegisteredOn().getTime());
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().getTime());
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
@@ -491,10 +475,11 @@
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
-          assertThat(
-                  cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
-          assertThat(cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -506,72 +491,93 @@
 
   @Test
   public void get() throws Exception {
-    AccountInfo info = gApi.accounts().id("admin").get();
-    assertThat(info.name).isEqualTo("Administrator");
-    assertThat(info.email).isEqualTo("admin@example.com");
-    assertThat(info.username).isEqualTo("admin");
-    accountIndexedCounter.assertNoReindex();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      AccountInfo info = gApi.accounts().id("admin").get();
+      assertThat(info.name).isEqualTo("Administrator");
+      assertThat(info.email).isEqualTo("admin@example.com");
+      assertThat(info.username).isEqualTo("admin");
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void getByIntId() throws Exception {
-    AccountInfo info = gApi.accounts().id("admin").get();
-    AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
-    assertThat(info.name).isEqualTo(infoByIntId.name);
-    accountIndexedCounter.assertNoReindex();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      AccountInfo info = gApi.accounts().id("admin").get();
+      AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
+      assertThat(info.name).isEqualTo(infoByIntId.name);
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void self() throws Exception {
-    AccountInfo info = gApi.accounts().self().get();
-    assertUser(info, admin);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      AccountInfo info = gApi.accounts().self().get();
+      assertUser(info, admin);
 
-    info = gApi.accounts().id("self").get();
-    assertUser(info, admin);
-    accountIndexedCounter.assertNoReindex();
+      info = gApi.accounts().id("self").get();
+      assertUser(info, admin);
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void active() throws Exception {
-    int id = gApi.accounts().id("user").get()._accountId;
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    gApi.accounts().id("user").setActive(false);
-    accountIndexedCounter.assertReindexOf(user);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      int id = gApi.accounts().id("user").get()._accountId;
+      assertThat(gApi.accounts().id("user").getActive()).isTrue();
+      gApi.accounts().id("user").setActive(false);
+      accountIndexedCounter.assertReindexOf(user);
 
-    // Inactive users may only be resolved by ID.
-    try {
-      gApi.accounts().id("user");
-      assert_().fail("expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e)
+      // Inactive users may only be resolved by ID.
+      ResourceNotFoundException thrown =
+          assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("user"));
+      assertThat(thrown)
           .hasMessageThat()
           .isEqualTo(
               "Account 'user' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
                   + ": User <user@example.com>");
-    }
-    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+      assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
-    gApi.accounts().id(id).setActive(true);
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    accountIndexedCounter.assertReindexOf(user);
+      gApi.accounts().id(id).setActive(true);
+      assertThat(gApi.accounts().id("user").getActive()).isTrue();
+      accountIndexedCounter.assertReindexOf(user);
+    }
   }
 
   @Test
   public void shouldAllowQueryByEmailForInactiveUser() throws Exception {
-    Account.Id activatableAccountId =
-        accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-    accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      Account.Id activatableAccountId =
+          accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
+      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+    }
 
     gApi.changes().query("owner:foo@activatable.com").get();
   }
 
   @Test
   public void shouldAllowQueryByUserNameForInactiveUser() throws Exception {
-    Account.Id activatableAccountId =
-        accountOperations.newAccount().inactive().username("foo").create();
-    accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      Account.Id activatableAccountId =
+          accountOperations.newAccount().inactive().username("foo").create();
+      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+    }
 
     gApi.changes().query("owner:foo").get();
   }
@@ -582,35 +588,33 @@
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
     Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
-    RegistrationHandle registrationHandle =
-        accountActivationValidationListeners.add(
-            "gerrit",
-            new AccountActivationValidationListener() {
-              @Override
-              public void validateActivation(AccountState account) throws ValidationException {
-                String preferredEmail = account.getAccount().getPreferredEmail();
-                if (preferredEmail == null || !preferredEmail.endsWith("@activatable.com")) {
-                  throw new ValidationException("not allowed to active account");
-                }
-              }
 
-              @Override
-              public void validateDeactivation(AccountState account) throws ValidationException {
-                String preferredEmail = account.getAccount().getPreferredEmail();
-                if (preferredEmail == null || !preferredEmail.endsWith("@deactivatable.com")) {
-                  throw new ValidationException("not allowed to deactive account");
-                }
-              }
-            });
-    try {
+    AccountActivationValidationListener listener =
+        new AccountActivationValidationListener() {
+          @Override
+          public void validateActivation(AccountState account) throws ValidationException {
+            String preferredEmail = account.account().preferredEmail();
+            if (preferredEmail == null || !preferredEmail.endsWith("@activatable.com")) {
+              throw new ValidationException("not allowed to active account");
+            }
+          }
+
+          @Override
+          public void validateDeactivation(AccountState account) throws ValidationException {
+            String preferredEmail = account.account().preferredEmail();
+            if (preferredEmail == null || !preferredEmail.endsWith("@deactivatable.com")) {
+              throw new ValidationException("not allowed to deactive account");
+            }
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
-      try {
-        gApi.accounts().id(activatableAccountId.get()).setActive(false);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("account not active");
-      }
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
 
       // Activate account that can be activated
@@ -622,12 +626,11 @@
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Try deactivating account that cannot be deactivated
-      try {
-        gApi.accounts().id(activatableAccountId.get()).setActive(false);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("not allowed to deactive account");
-      }
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       /* Test account that can be deactivated, but not activated */
@@ -640,32 +643,29 @@
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Deactivate account that is already inactive
-      try {
-        gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("account not active");
-      }
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Try activating account that cannot be activated
-      try {
-        gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("not allowed to active account");
-      }
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
+      assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-    } finally {
-      registrationHandle.remove();
     }
   }
 
   @Test
   public void deactivateSelf() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot deactivate own account");
-    gApi.accounts().self().setActive(false);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().setActive(false));
+    assertThat(thrown).hasMessageThat().contains("cannot deactivate own account");
   }
 
   @Test
@@ -674,107 +674,125 @@
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
     assertThat(gApi.accounts().id(id).getActive()).isFalse();
-    try {
-      gApi.accounts().id(id).setActive(false);
-      fail("Expected exception");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage()).isEqualTo("account not active");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().id(id).setActive(false));
+    assertThat(thrown).hasMessageThat().isEqualTo("account not active");
     gApi.accounts().id(id).setActive(true);
   }
 
   @Test
   public void starUnstarChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    refUpdateCounter.clear();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter).add(refUpdateCounter)) {
+      PushOneCommit.Result r = createChange();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      refUpdateCounter.clear();
 
-    gApi.accounts().self().starChange(triplet);
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).contains(DEFAULT_LABEL);
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+      gApi.accounts().self().starChange(triplet);
+      ChangeInfo change = info(triplet);
+      assertThat(change.starred).isTrue();
+      assertThat(change.stars).contains(DEFAULT_LABEL);
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(
+              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
-    gApi.accounts().self().unstarChange(triplet);
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).isNull();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+      gApi.accounts().self().unstarChange(triplet);
+      change = info(triplet);
+      assertThat(change.starred).isNull();
+      assertThat(change.stars).isNull();
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(
+              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
-    accountIndexedCounter.assertNoReindex();
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void starUnstarChangeWithLabels() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    refUpdateCounter.clear();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter).add(refUpdateCounter)) {
+      PushOneCommit.Result r = createChange();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      refUpdateCounter.clear();
 
-    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
-    assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
+      assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+      assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
 
-    gApi.accounts()
-        .self()
-        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-    assertThat(gApi.accounts().self().getStars(triplet))
-        .containsExactly("blue", "red", DEFAULT_LABEL)
-        .inOrder();
-    List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
-    assertThat(starredChanges).hasSize(1);
-    ChangeInfo starredChange = starredChanges.get(0);
-    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-    assertThat(starredChange.starred).isTrue();
-    assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+      gApi.accounts()
+          .self()
+          .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
+      ChangeInfo change = info(triplet);
+      assertThat(change.starred).isTrue();
+      assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+      assertThat(gApi.accounts().self().getStars(triplet))
+          .containsExactly("blue", "red", DEFAULT_LABEL)
+          .inOrder();
+      List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
+      assertThat(starredChanges).hasSize(1);
+      ChangeInfo starredChange = starredChanges.get(0);
+      assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+      assertThat(starredChange.starred).isTrue();
+      assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(
+              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
-    gApi.accounts()
-        .self()
-        .setStars(
-            triplet,
-            new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).containsExactly("red", "yellow").inOrder();
-    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly("red", "yellow").inOrder();
-    starredChanges = gApi.accounts().self().getStarredChanges();
-    assertThat(starredChanges).hasSize(1);
-    starredChange = starredChanges.get(0);
-    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-    assertThat(starredChange.starred).isNull();
-    assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+      gApi.accounts()
+          .self()
+          .setStars(
+              triplet,
+              new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
+      change = info(triplet);
+      assertThat(change.starred).isNull();
+      assertThat(change.stars).containsExactly("red", "yellow").inOrder();
+      assertThat(gApi.accounts().self().getStars(triplet))
+          .containsExactly("red", "yellow")
+          .inOrder();
+      starredChanges = gApi.accounts().self().getStarredChanges();
+      assertThat(starredChanges).hasSize(1);
+      starredChange = starredChanges.get(0);
+      assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+      assertThat(starredChange.starred).isNull();
+      assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(
+              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
-    accountIndexedCounter.assertNoReindex();
+      accountIndexedCounter.assertNoReindex();
 
-    requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to get stars of another account");
-    gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet);
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown =
+          assertThrows(
+              AuthException.class,
+              () -> gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet));
+      assertThat(thrown).hasMessageThat().contains("not allowed to get stars of another account");
+    }
   }
 
   @Test
   public void starWithInvalidLabels() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: another invalid label, invalid label");
-    gApi.accounts()
-        .self()
-        .setStars(
-            triplet,
-            new StarsInput(
-                ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue", "another invalid label")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(
+                            ImmutableSet.of(
+                                DEFAULT_LABEL, "invalid label", "blue", "another invalid label"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid labels: another invalid label, invalid label");
   }
 
   @Test
@@ -792,65 +810,84 @@
   public void starWithDefaultAndIgnoreLabel() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + DEFAULT_LABEL
-            + " and "
-            + IGNORE_LABEL
-            + " are mutually exclusive."
-            + " Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + DEFAULT_LABEL
+                + " and "
+                + IGNORE_LABEL
+                + " are mutually exclusive."
+                + " Only one of them can be set.");
   }
 
   @Test
   public void ignoreChangeBySetStars() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-    accountIndexedCounter.clear();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount user2 = accountCreator.user2();
+      accountIndexedCounter.clear();
 
-    PushOneCommit.Result r = createChange();
+      PushOneCommit.Result r = createChange();
 
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = user.email();
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    in = new AddReviewerInput();
-    in.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+      in = new AddReviewerInput();
+      in.reviewer = user2.email();
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+      requestScopeOperations.setApiUser(user.id());
+      gApi.accounts()
+          .self()
+          .setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
-    sender.clear();
-    requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(r.getChangeId()).abandon();
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user2.getEmailAddress());
-    accountIndexedCounter.assertNoReindex();
+      sender.clear();
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.changes().id(r.getChangeId()).abandon();
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(user2.getEmailAddress());
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void addReviewerToIgnoredChange() throws Exception {
-    PushOneCommit.Result r = createChange();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      PushOneCommit.Result r = createChange();
 
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+      requestScopeOperations.setApiUser(user.id());
+      gApi.accounts()
+          .self()
+          .setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
-    sender.clear();
-    requestScopeOperations.setApiUser(admin.id());
+      sender.clear();
+      requestScopeOperations.setApiUser(admin.id());
 
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message message = messages.get(0);
-    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
-    assertMailReplyTo(message, admin.email());
-    accountIndexedCounter.assertNoReindex();
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = user.email();
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      Message message = messages.get(0);
+      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertMailReplyTo(message, admin.email());
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
@@ -948,17 +985,21 @@
 
   @Test
   public void suggestAccounts() throws Exception {
-    String adminUsername = "admin";
-    List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).username).isEqualTo(adminUsername);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String adminUsername = "admin";
+      List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
+      assertThat(result).hasSize(1);
+      assertThat(result.get(0).username).isEqualTo(adminUsername);
 
-    List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
-    assertThat(resultShortcutApi).hasSize(result.size());
+      List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
+      assertThat(resultShortcutApi).hasSize(result.size());
 
-    List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
-    assertThat(emptyResult).isEmpty();
-    accountIndexedCounter.assertNoReindex();
+      List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
+      assertThat(emptyResult).isEmpty();
+      accountIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
@@ -982,7 +1023,7 @@
     assertThat(detail.email).isEqualTo(email);
     assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
     assertThat(detail.status).isEqualTo(status);
-    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).getRegisteredOn());
+    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).registeredOn());
     assertThat(detail.inactive).isNull();
     assertThat(detail._moreAccounts).isNull();
   }
@@ -1024,7 +1065,7 @@
     requestScopeOperations.setApiUser(admin.id());
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id().hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
     requestScopeOperations.setApiUser(foo.id());
     assertThat(getEmails()).containsExactly(email, secondaryEmail);
@@ -1036,9 +1077,9 @@
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(foo.id().get()).getEmails();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(foo.id().get()).getEmails());
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -1047,7 +1088,7 @@
     String secondaryEmail = "secondary3@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id().hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
     assertThat(
             gApi.accounts().id(foo.id().get()).getEmails().stream()
@@ -1058,17 +1099,21 @@
 
   @Test
   public void addEmail() throws Exception {
-    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
-    Set<String> currentEmails = getEmails();
-    for (String email : emails) {
-      assertThat(currentEmails).doesNotContain(email);
-      EmailInput input = newEmailInput(email);
-      gApi.accounts().self().addEmail(input);
-      accountIndexedCounter.assertReindexOf(admin);
-    }
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
+      Set<String> currentEmails = getEmails();
+      for (String email : emails) {
+        assertThat(currentEmails).doesNotContain(email);
+        EmailInput input = newEmailInput(email);
+        gApi.accounts().self().addEmail(input);
+        accountIndexedCounter.assertReindexOf(admin);
+      }
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).containsAtLeastElementsIn(emails);
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).containsAtLeastElementsIn(emails);
+    }
   }
 
   @Test
@@ -1086,16 +1131,17 @@
 
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
             "new.email@example.africa");
-    for (String email : emails) {
-      EmailInput input = newEmailInput(email);
-      try {
-        gApi.accounts().self().addEmail(input);
-        fail("Expected BadRequestException for invalid email address: " + email);
-      } catch (BadRequestException e) {
-        assertThat(e).hasMessageThat().isEqualTo("invalid email address");
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      for (String email : emails) {
+        EmailInput input = newEmailInput(email);
+        BadRequestException thrown =
+            assertThrows(BadRequestException.class, () -> gApi.accounts().self().addEmail(input));
+        assertWithMessage(email).that(thrown).hasMessageThat().isEqualTo("invalid email address");
       }
+      accountIndexedCounter.assertNoReindex();
     }
-    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -1103,8 +1149,7 @@
     TestAccount account = accountCreator.create(name("user"));
     EmailInput input = newEmailInput("test@test.com");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(account.username()).addEmail(input);
+    assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
   }
 
   @Test
@@ -1112,9 +1157,13 @@
     String email = "new.email@example.com";
     EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
-    gApi.accounts().id(user.username()).addEmail(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(user.username()).addEmail(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'mailto:" + email + "' in use by another account");
   }
 
   @Test
@@ -1150,9 +1199,14 @@
     TestAccount user = accountCreator.create();
     requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id().get()).addEmail(newEmailInput("foo@example.com", false));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .addEmail(newEmailInput("foo@example.com", false)));
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -1167,62 +1221,74 @@
 
   @Test
   public void addEmailAndSetPreferred() throws Exception {
-    String email = "foo.bar@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
-    input.preferred = true;
-    gApi.accounts().self().addEmail(input);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String email = "foo.bar@example.com";
+      EmailInput input = new EmailInput();
+      input.email = email;
+      input.noConfirmation = true;
+      input.preferred = true;
+      gApi.accounts().self().addEmail(input);
 
-    // Account is reindexed twice; once on adding the new email,
-    // and then again on setting the email preferred.
-    accountIndexedCounter.assertReindexOf(admin, 2);
+      // Account is reindexed twice; once on adding the new email,
+      // and then again on setting the email preferred.
+      accountIndexedCounter.assertReindexOf(admin, 2);
 
-    String preferred = gApi.accounts().self().get().email;
-    assertThat(preferred).isEqualTo(email);
+      String preferred = gApi.accounts().self().get().email;
+      assertThat(preferred).isEqualTo(email);
+    }
   }
 
   @Test
   public void deleteEmail() throws Exception {
-    String email = "foo.bar@example.com";
-    EmailInput input = newEmailInput(email);
-    gApi.accounts().self().addEmail(input);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String email = "foo.bar@example.com";
+      EmailInput input = newEmailInput(email);
+      gApi.accounts().self().addEmail(input);
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).contains(email);
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).contains(email);
 
-    accountIndexedCounter.clear();
-    gApi.accounts().self().deleteEmail(input.email);
-    accountIndexedCounter.assertReindexOf(admin);
+      accountIndexedCounter.clear();
+      gApi.accounts().self().deleteEmail(input.email);
+      accountIndexedCounter.assertReindexOf(admin);
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).doesNotContain(email);
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).doesNotContain(email);
+    }
   }
 
   @Test
   public void deletePreferredEmail() throws Exception {
-    String previous = gApi.accounts().self().get().email;
-    String email = "foo.bar.baz@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
-    input.preferred = true;
-    gApi.accounts().self().addEmail(input);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String previous = gApi.accounts().self().get().email;
+      String email = "foo.bar.baz@example.com";
+      EmailInput input = new EmailInput();
+      input.email = email;
+      input.noConfirmation = true;
+      input.preferred = true;
+      gApi.accounts().self().addEmail(input);
 
-    // Account is reindexed twice; once on adding the new email,
-    // and then again on setting the email preferred.
-    accountIndexedCounter.assertReindexOf(admin, 2);
+      // Account is reindexed twice; once on adding the new email,
+      // and then again on setting the email preferred.
+      accountIndexedCounter.assertReindexOf(admin, 2);
 
-    // The new preferred email is set
-    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+      // The new preferred email is set
+      assertThat(gApi.accounts().self().get().email).isEqualTo(email);
 
-    accountIndexedCounter.clear();
-    gApi.accounts().self().deleteEmail(input.email);
-    accountIndexedCounter.assertReindexOf(admin);
+      accountIndexedCounter.clear();
+      gApi.accounts().self().deleteEmail(input.email);
+      accountIndexedCounter.assertReindexOf(admin);
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).containsExactly(previous);
-    assertThat(gApi.accounts().self().get().email).isNull();
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).containsExactly(previous);
+      assertThat(gApi.accounts().self().get().email).isNull();
+    }
   }
 
   @Test
@@ -1248,36 +1314,45 @@
 
   @Test
   public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
-    String email = "foo.bar@example.com";
-    String extId1 = "foo:bar";
-    String extId2 = "foo:baz";
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Add External IDs",
-            admin.id(),
-            u ->
-                u.addExternalId(
-                        ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id(), email))
-                    .addExternalId(
-                        ExternalId.createWithEmail(
-                            ExternalId.Key.parse(extId2), admin.id(), email)));
-    accountIndexedCounter.assertReindexOf(admin);
-    assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
-        .containsAtLeast(extId1, extId2);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String email = "foo.bar@example.com";
+      String extId1 = "foo:bar";
+      String extId2 = "foo:baz";
+      accountsUpdateProvider
+          .get()
+          .update(
+              "Add External IDs",
+              admin.id(),
+              u ->
+                  u.addExternalId(
+                          ExternalId.createWithEmail(
+                              ExternalId.Key.parse(extId1), admin.id(), email))
+                      .addExternalId(
+                          ExternalId.createWithEmail(
+                              ExternalId.Key.parse(extId2), admin.id(), email)));
+      accountIndexedCounter.assertReindexOf(admin);
+      assertThat(
+              gApi.accounts().self().getExternalIds().stream()
+                  .map(e -> e.identity)
+                  .collect(toSet()))
+          .containsAtLeast(extId1, extId2);
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).contains(email);
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).contains(email);
 
-    gApi.accounts().self().deleteEmail(email);
-    accountIndexedCounter.assertReindexOf(admin);
+      gApi.accounts().self().deleteEmail(email);
+      accountIndexedCounter.assertReindexOf(admin);
 
-    requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).doesNotContain(email);
-    assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
-        .containsNoneOf(extId1, extId2);
+      requestScopeOperations.resetCurrentApiUser();
+      assertThat(getEmails()).doesNotContain(email);
+      assertThat(
+              gApi.accounts().self().getExternalIds().stream()
+                  .map(e -> e.identity)
+                  .collect(toSet()))
+          .containsNoneOf(extId1, extId2);
+    }
   }
 
   @Test
@@ -1294,7 +1369,6 @@
                 u.addExternalId(
                     ExternalId.createWithEmail(
                         ExternalId.Key.parse(ldapExternalId), admin.id(), ldapEmail)));
-    accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .contains(ldapExternalId);
@@ -1333,7 +1407,6 @@
                     .addExternalId(
                         ExternalId.createWithEmail(
                             ExternalId.Key.parse(ldapExternalId), admin.id(), ldapEmail)));
-    accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
@@ -1352,28 +1425,34 @@
 
   @Test
   public void deleteEmailOfOtherUser() throws Exception {
-    String email = "foo.bar@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
-    gApi.accounts().id(user.id().get()).addEmail(input);
-    accountIndexedCounter.assertReindexOf(user);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String email = "foo.bar@example.com";
+      EmailInput input = new EmailInput();
+      input.email = email;
+      input.noConfirmation = true;
+      gApi.accounts().id(user.id().get()).addEmail(input);
+      accountIndexedCounter.assertReindexOf(user);
 
-    requestScopeOperations.setApiUser(user.id());
-    assertThat(getEmails()).contains(email);
+      requestScopeOperations.setApiUser(user.id());
+      assertThat(getEmails()).contains(email);
 
-    // admin can delete email of user
-    requestScopeOperations.setApiUser(admin.id());
-    gApi.accounts().id(user.id().get()).deleteEmail(email);
-    accountIndexedCounter.assertReindexOf(user);
+      // admin can delete email of user
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.accounts().id(user.id().get()).deleteEmail(email);
+      accountIndexedCounter.assertReindexOf(user);
 
-    requestScopeOperations.setApiUser(user.id());
-    assertThat(getEmails()).doesNotContain(email);
+      requestScopeOperations.setApiUser(user.id());
+      assertThat(getEmails()).doesNotContain(email);
 
-    // user cannot delete email of admin
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id().get()).deleteEmail(admin.email());
+      // user cannot delete email of admin
+      AuthException thrown =
+          assertThrows(
+              AuthException.class,
+              () -> gApi.accounts().id(admin.id().get()).deleteEmail(admin.email()));
+      assertThat(thrown).hasMessageThat().contains("modify account not permitted");
+    }
   }
 
   @Test
@@ -1439,17 +1518,21 @@
   public void putStatus() throws Exception {
     List<String> statuses = ImmutableList.of("OOO", "Busy");
     AccountInfo info;
-    for (String status : statuses) {
-      gApi.accounts().self().setStatus(status);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      for (String status : statuses) {
+        gApi.accounts().self().setStatus(status);
+        info = gApi.accounts().self().get();
+        assertUser(info, admin, status);
+        accountIndexedCounter.assertReindexOf(admin);
+      }
+
+      gApi.accounts().self().setStatus(null);
       info = gApi.accounts().self().get();
-      assertUser(info, admin, status);
+      assertUser(info, admin);
       accountIndexedCounter.assertReindexOf(admin);
     }
-
-    gApi.accounts().self().setStatus(null);
-    info = gApi.accounts().self().get();
-    assertUser(info, admin);
-    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
@@ -1467,617 +1550,92 @@
   @Test
   public void userCannotSetNameOfOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setName("Admin McAdminface");
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setName("Admin McAdminface"));
   }
 
   @Test
   @Sandboxed
   public void userCanSetNameOfOtherUserWithModifyAccountPermission() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.MODIFY_ACCOUNT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
     gApi.accounts().id(admin.username()).setName("Admin McAdminface");
     assertThat(gApi.accounts().id(admin.username()).get().name).isEqualTo("Admin McAdminface");
   }
 
   @Test
   public void fetchUserBranch() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      requestScopeOperations.setApiUser(user.id());
 
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
-    String userRefName = RefNames.refsUsers(user.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+      String userRefName = RefNames.refsUsers(user.id());
 
-    // remove default READ permissions
-    try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      u.getConfig()
-          .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-          .remove(new Permission(Permission.READ));
-      u.save();
-    }
+      // remove default READ permissions
+      try (ProjectConfigUpdate u = updateProject(allUsers)) {
+        u.getConfig()
+            .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
+            .remove(new Permission(Permission.READ));
+        u.save();
+      }
 
-    // deny READ permission that is inherited from All-Projects
-    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
+      // deny READ permission that is inherited from All-Projects
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(deny(Permission.READ).ref(RefNames.REFS + "*").group(ANONYMOUS_USERS))
+          .update();
 
-    // fetching user branch without READ permission fails
-    try {
+      // fetching user branch without READ permission fails
+      assertThrows(TransportException.class, () -> fetch(allUsersRepo, userRefName + ":userRef"));
+
+      // allow each user to read its own user branch
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(
+              allow(Permission.READ)
+                  .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                  .group(REGISTERED_USERS))
+          .update();
+
+      // fetch user branch using refs/users/YY/XXXXXXX
       fetch(allUsersRepo, userRefName + ":userRef");
-      fail("user branch is visible although no READ permission is granted");
-    } catch (TransportException e) {
-      // expected because no READ granted on user branch
-    }
+      Ref userRef = allUsersRepo.getRepository().exactRef("userRef");
+      assertThat(userRef).isNotNull();
 
-    // allow each user to read its own user branch
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.READ,
-        false,
-        REGISTERED_USERS);
+      // fetch user branch using refs/users/self
+      fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
+      Ref userSelfRef = allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
+      assertThat(userSelfRef).isNotNull();
+      assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
 
-    // fetch user branch using refs/users/YY/XXXXXXX
-    fetch(allUsersRepo, userRefName + ":userRef");
-    Ref userRef = allUsersRepo.getRepository().exactRef("userRef");
-    assertThat(userRef).isNotNull();
+      accountIndexedCounter.assertNoReindex();
 
-    // fetch user branch using refs/users/self
-    fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
-    Ref userSelfRef = allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
-    assertThat(userSelfRef).isNotNull();
-    assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
-
-    accountIndexedCounter.assertNoReindex();
-
-    // fetching user branch of another user fails
-    String otherUserRefName = RefNames.refsUsers(admin.id());
-    exception.expect(TransportException.class);
-    exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
-    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
-  }
-
-  @Test
-  public void pushToUserBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
-    push.to(RefNames.refsUsers(admin.id())).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    push = pushFactory.create(admin.newIdent(), allUsersRepo);
-    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  @Test
-  public void pushToUserBranchForReview() throws Exception {
-    String userRefName = RefNames.refsUsers(admin.id());
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRefName + ":userRef");
-    allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
-    PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    push = pushFactory.create(admin.newIdent(), allUsersRepo);
-    r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
-    String userRef = RefNames.refsUsers(admin.id());
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(admin.email());
-    assertThat(info.name).isEqualTo(admin.fullName());
-    assertThat(info.status).isEqualTo("out-of-office");
-  }
-
-  @Test
-  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
-      throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id());
-    accountIndexedCounter.clear();
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String email = "some.email@example.com";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                foo.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    requestScopeOperations.setApiUser(foo.id());
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName());
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id());
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                "invalid config")
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountProperties.ACCOUNT_CONFIG,
-            admin.id(),
-            AccountProperties.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id());
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String noEmail = "no.email";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: invalid preferred email '%s' for account '%s'",
-            noEmail, admin.id()));
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id());
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("invalid account configuration: cannot deactivate own account");
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id());
-    accountIndexedCounter.clear();
-
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroupUuid(), false);
-    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
-  }
-
-  @Test
-  public void pushWatchConfigToUserBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config wc = new Config();
-    wc.setString(
-        ProjectWatches.PROJECT,
-        project.get(),
-        ProjectWatches.KEY_NOTIFY,
-        ProjectWatches.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            allUsersRepo,
-            "Add project watch",
-            ProjectWatches.WATCH_CONFIG,
-            wc.toText());
-    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    String invalidNotifyValue = "]invalid[";
-    wc.setString(
-        ProjectWatches.PROJECT, project.get(), ProjectWatches.KEY_NOTIFY, invalidNotifyValue);
-    push =
-        pushFactory.create(
-            admin.newIdent(),
-            allUsersRepo,
-            "Add invalid project watch",
-            ProjectWatches.WATCH_CONFIG,
-            wc.toText());
-    PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage(
-        String.format(
-            "%s: Invalid project watch of account %d for project %s: %s",
-            ProjectWatches.WATCH_CONFIG, admin.id().get(), project.get(), invalidNotifyValue));
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranch() throws Exception {
-    TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
-    requestScopeOperations.setApiUser(oooUser.id());
-
-    // Must clone as oooUser to ensure the push is allowed.
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
-    fetch(allUsersRepo, RefNames.refsUsers(oooUser.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
-
-    accountIndexedCounter.clear();
-    pushFactory
-        .create(
-            oooUser.newIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountProperties.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(RefNames.refsUsers(oooUser.id()))
-        .assertOkStatus();
-
-    accountIndexedCounter.assertReindexOf(oooUser);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(oooUser.email());
-    assertThat(info.name).isEqualTo(oooUser.fullName());
-    assertThat(info.status).isEqualTo("out-of-office");
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                "invalid config")
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage(
-        String.format(
-            "commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountProperties.ACCOUNT_CONFIG,
-            admin.id(),
-            AccountProperties.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String noEmail = "no.email";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage(
-        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id()));
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id());
-
-    String noEmail = "no.email";
-    accountsUpdateProvider
-        .get()
-        .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
-    accountIndexedCounter.clear();
-
-    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String status = "in vacation";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, status);
-
-    pushFactory
-        .create(
-            foo.newIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountProperties.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-    assertThat(info.email).isEqualTo(noEmail);
-    assertThat(info.name).isEqualTo(foo.fullName());
-    assertThat(info.status).isEqualTo(status);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id());
-    accountIndexedCounter.clear();
-
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String email = "some.email@example.com";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
-
-    pushFactory
-        .create(
-            foo.newIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountProperties.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-    assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName());
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountProperties.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage("cannot deactivate own account");
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id());
-    accountIndexedCounter.clear();
-
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-    pushFactory
-        .create(
-            admin.newIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountProperties.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
-  }
-
-  @Test
-  public void cannotCreateUserBranch() throws Exception {
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
+      // fetching user branch of another user fails
+      String otherUserRefName = RefNames.refsUsers(admin.id());
+      TransportException thrown =
+          assertThrows(
+              TransportException.class,
+              () -> fetch(allUsersRepo, otherUserRefName + ":otherUserRef"));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Remote does not have " + otherUserRefName + " available for fetch.");
     }
   }
 
   @Test
-  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNotNull();
-    }
-  }
-
-  @Test
-  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
-      throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.REFS_USERS + "foo";
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
+  public void refsUsersSelfIsAdvertised() throws Exception {
+    TestRepository<?> testRepository = cloneProject(allUsers, user);
+    try (Git git = testRepository.git()) {
+      List<String> advertisedRefs =
+          git.lsRemote().call().stream().map(Ref::getName).collect(toList());
+      assertThat(advertisedRefs).contains(RefNames.REFS_USERS_SELF);
     }
   }
 
@@ -2087,8 +1645,12 @@
       assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
     }
 
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     pushFactory
@@ -2103,12 +1665,15 @@
 
   @Test
   public void cannotDeleteUserBranch() throws Exception {
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     String userRef = RefNames.refsUsers(admin.id());
@@ -2124,13 +1689,19 @@
 
   @Test
   public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     String userRef = RefNames.refsUsers(admin.id());
@@ -2159,9 +1730,10 @@
     assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+    assertThat(thrown).hasMessageThat().contains(id);
   }
 
   @Test
@@ -2171,8 +1743,7 @@
 
     sender.clear();
     requestScopeOperations.setApiUser(admin.id());
-    exception.expect(ResourceNotFoundException.class);
-    addGpgKey(user, key.getPublicKeyArmored());
+    assertThrows(ResourceNotFoundException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
   }
 
   @Test
@@ -2201,212 +1772,251 @@
 
   @Test
   public void addOtherUsersGpgKey_Conflict() throws Exception {
-    // Both users have a matching external ID for this key.
-    addExternalIdEmail(admin, "test5@example.com");
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Add External ID",
-            user.id(),
-            u -> u.addExternalId(ExternalId.create("foo", "myId", user.id())));
-    accountIndexedCounter.assertReindexOf(user);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      // Both users have a matching external ID for this key.
+      addExternalIdEmail(admin, "test5@example.com");
+      accountIndexedCounter.clear();
+      accountsUpdateProvider
+          .get()
+          .update(
+              "Add External ID",
+              user.id(),
+              u -> u.addExternalId(ExternalId.create("foo", "myId", user.id())));
+      accountIndexedCounter.assertReindexOf(user);
 
-    TestKey key = validKeyWithSecondUserId();
-    addGpgKey(key.getPublicKeyArmored());
-    requestScopeOperations.setApiUser(user.id());
+      TestKey key = validKeyWithSecondUserId();
+      addGpgKey(key.getPublicKeyArmored());
+      requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("GPG key already associated with another account");
-    addGpgKey(user, key.getPublicKeyArmored());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("GPG key already associated with another account");
+    }
   }
 
   @Test
   public void listGpgKeys() throws Exception {
-    List<TestKey> keys = allValidKeys();
-    List<String> toAdd = new ArrayList<>(keys.size());
-    for (TestKey key : keys) {
-      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
-      toAdd.add(key.getPublicKeyArmored());
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      List<TestKey> keys = allValidKeys();
+      List<String> toAdd = new ArrayList<>(keys.size());
+      for (TestKey key : keys) {
+        addExternalIdEmail(
+            admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+        toAdd.add(key.getPublicKeyArmored());
+      }
+      accountIndexedCounter.clear();
+      gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.of());
+      assertKeys(keys);
+      accountIndexedCounter.assertReindexOf(admin);
     }
-    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.of());
-    assertKeys(keys);
-    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void deleteGpgKey() throws Exception {
-    TestKey key = validKeyWithoutExpiration();
-    String id = key.getKeyIdString();
-    addExternalIdEmail(admin, "test1@example.com");
-    addGpgKey(key.getPublicKeyArmored());
-    assertKeys(key);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestKey key = validKeyWithoutExpiration();
+      String id = key.getKeyIdString();
+      addExternalIdEmail(admin, "test1@example.com");
+      addGpgKey(key.getPublicKeyArmored());
+      assertKeys(key);
+      accountIndexedCounter.clear();
 
-    sender.clear();
-    gApi.accounts().self().gpgKey(id).delete();
-    accountIndexedCounter.assertReindexOf(admin);
-    assertKeys();
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).body()).contains("GPG keys have been deleted");
+      sender.clear();
+      gApi.accounts().self().gpgKey(id).delete();
+      accountIndexedCounter.assertReindexOf(admin);
+      assertKeys();
+      assertThat(sender.getMessages()).hasSize(1);
+      assertThat(sender.getMessages().get(0).body()).contains("GPG keys have been deleted");
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+      ResourceNotFoundException thrown =
+          assertThrows(
+              ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+      assertThat(thrown).hasMessageThat().contains(id);
+    }
   }
 
   @Test
   public void addAndRemoveGpgKeys() throws Exception {
-    for (TestKey key : allValidKeys()) {
-      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      for (TestKey key : allValidKeys()) {
+        addExternalIdEmail(
+            admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+      }
+      accountIndexedCounter.clear();
+      TestKey key1 = validKeyWithoutExpiration();
+      TestKey key2 = validKeyWithExpiration();
+      TestKey key5 = validKeyWithSecondUserId();
+
+      Map<String, GpgKeyInfo> infos =
+          gApi.accounts()
+              .self()
+              .putGpgKeys(
+                  ImmutableList.of(key1.getPublicKeyArmored(), key2.getPublicKeyArmored()),
+                  ImmutableList.of(key5.getKeyIdString()));
+      assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
+      assertKeys(key1, key2);
+      accountIndexedCounter.assertReindexOf(admin);
+
+      infos =
+          gApi.accounts()
+              .self()
+              .putGpgKeys(
+                  ImmutableList.of(key5.getPublicKeyArmored()),
+                  ImmutableList.of(key1.getKeyIdString()));
+      assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
+      assertKeyMapContains(key5, infos);
+      assertThat(infos.get(key1.getKeyIdString()).key).isNull();
+      assertKeys(key2, key5);
+      accountIndexedCounter.assertReindexOf(admin);
+
+      BadRequestException thrown =
+          assertThrows(
+              BadRequestException.class,
+              () ->
+                  gApi.accounts()
+                      .self()
+                      .putGpgKeys(
+                          ImmutableList.of(key2.getPublicKeyArmored()),
+                          ImmutableList.of(key2.getKeyIdString())));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
     }
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key2 = validKeyWithExpiration();
-    TestKey key5 = validKeyWithSecondUserId();
-
-    Map<String, GpgKeyInfo> infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key1.getPublicKeyArmored(), key2.getPublicKeyArmored()),
-                ImmutableList.of(key5.getKeyIdString()));
-    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
-    assertKeys(key1, key2);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key5.getPublicKeyArmored()),
-                ImmutableList.of(key1.getKeyIdString()));
-    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
-    assertKeyMapContains(key5, infos);
-    assertThat(infos.get(key1.getKeyIdString()).key).isNull();
-    assertKeys(key2, key5);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
-    gApi.accounts()
-        .self()
-        .putGpgKeys(
-            ImmutableList.of(key2.getPublicKeyArmored()), ImmutableList.of(key2.getKeyIdString()));
   }
 
   @Test
   public void addMalformedGpgKey() throws Exception {
     String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Failed to parse GPG keys");
-    addGpgKey(key);
+    BadRequestException thrown = assertThrows(BadRequestException.class, () -> addGpgKey(key));
+    assertThat(thrown).hasMessageThat().contains("Failed to parse GPG keys");
   }
 
   @Test
   @UseSsh
   public void sshKeys() throws Exception {
-    // The test account should initially have exactly one ssh key
-    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(1);
-    assertSequenceNumbers(info);
-    SshKeyInfo key = info.get(0);
-    KeyPair keyPair = sshKeys.getKeyPair(admin);
-    String initial = TestSshKeys.publicKey(keyPair, admin.email());
-    assertThat(key.sshPublicKey).isEqualTo(initial);
-    accountIndexedCounter.assertNoReindex();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      // The test account should initially have exactly one ssh key
+      List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(1);
+      assertSequenceNumbers(info);
+      SshKeyInfo key = info.get(0);
+      KeyPair keyPair = sshKeys.getKeyPair(admin);
+      String initial = TestSshKeys.publicKey(keyPair, admin.email());
+      assertThat(key.sshPublicKey).isEqualTo(initial);
+      accountIndexedCounter.assertNoReindex();
 
-    // Add a new key
-    sender.clear();
-    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
-    gApi.accounts().self().addSshKey(newKey);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertReindexOf(admin);
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
+      // Add a new key
+      sender.clear();
+      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      gApi.accounts().self().addSshKey(newKey);
+      info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(2);
+      assertSequenceNumbers(info);
+      accountIndexedCounter.assertReindexOf(admin);
+      assertThat(sender.getMessages()).hasSize(1);
+      assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
-    // Add an existing key (the request succeeds, but the key isn't added again)
-    sender.clear();
-    gApi.accounts().self().addSshKey(initial);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertNoReindex();
-    // TODO: Issue 10769: Adding an already existing key should not result in a notification email
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
+      // Add an existing key (the request succeeds, but the key isn't added again)
+      sender.clear();
+      gApi.accounts().self().addSshKey(initial);
+      info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(2);
+      assertSequenceNumbers(info);
+      accountIndexedCounter.assertNoReindex();
+      // TODO: Issue 10769: Adding an already existing key should not result in a notification email
+      assertThat(sender.getMessages()).hasSize(1);
+      assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
-    // Add another new key
-    sender.clear();
-    String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
-    gApi.accounts().self().addSshKey(newKey2);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(3);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertReindexOf(admin);
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
+      // Add another new key
+      sender.clear();
+      String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      gApi.accounts().self().addSshKey(newKey2);
+      info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(3);
+      assertSequenceNumbers(info);
+      accountIndexedCounter.assertReindexOf(admin);
+      assertThat(sender.getMessages()).hasSize(1);
+      assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
-    // Delete second key
-    sender.clear();
-    gApi.accounts().self().deleteSshKey(2);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertThat(info.get(0).seq).isEqualTo(1);
-    assertThat(info.get(1).seq).isEqualTo(3);
-    accountIndexedCounter.assertReindexOf(admin);
+      // Delete second key
+      sender.clear();
+      gApi.accounts().self().deleteSshKey(2);
+      info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(2);
+      assertThat(info.get(0).seq).isEqualTo(1);
+      assertThat(info.get(1).seq).isEqualTo(3);
+      accountIndexedCounter.assertReindexOf(admin);
 
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).body()).contains("SSH keys have been deleted");
+      assertThat(sender.getMessages()).hasSize(1);
+      assertThat(sender.getMessages().get(0).body()).contains("SSH keys have been deleted");
 
-    // Mark first key as invalid
-    assertThat(info.get(0).valid).isTrue();
-    authorizedKeys.markKeyInvalid(admin.id(), 1);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertThat(info.get(0).seq).isEqualTo(1);
-    assertThat(info.get(0).valid).isFalse();
-    assertThat(info.get(1).seq).isEqualTo(3);
-    accountIndexedCounter.assertReindexOf(admin);
+      // Mark first key as invalid
+      assertThat(info.get(0).valid).isTrue();
+      authorizedKeys.markKeyInvalid(admin.id(), 1);
+      info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(2);
+      assertThat(info.get(0).seq).isEqualTo(1);
+      assertThat(info.get(0).valid).isFalse();
+      assertThat(info.get(1).seq).isEqualTo(3);
+      accountIndexedCounter.assertReindexOf(admin);
+    }
   }
 
   @Test
   @UseSsh
   public void adminCanAddOrRemoveSshKeyOnOtherAccount() throws Exception {
-    // The test account should initially have exactly one ssh key
-    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(1);
-    assertSequenceNumbers(info);
-    SshKeyInfo key = info.get(0);
-    KeyPair keyPair = sshKeys.getKeyPair(admin);
-    String initial = TestSshKeys.publicKey(keyPair, admin.email());
-    assertThat(key.sshPublicKey).isEqualTo(initial);
-    accountIndexedCounter.assertNoReindex();
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      // The test account should initially have exactly one ssh key
+      List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
+      assertThat(info).hasSize(1);
+      assertSequenceNumbers(info);
+      SshKeyInfo key = info.get(0);
+      KeyPair keyPair = sshKeys.getKeyPair(admin);
+      String initial = TestSshKeys.publicKey(keyPair, admin.email());
+      assertThat(key.sshPublicKey).isEqualTo(initial);
+      accountIndexedCounter.assertNoReindex();
 
-    // Add a new key
-    sender.clear();
-    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), user.email());
-    gApi.accounts().id(user.username()).addSshKey(newKey);
-    info = gApi.accounts().id(user.username()).listSshKeys();
-    assertThat(info).hasSize(2);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertReindexOf(user);
+      // Add a new key
+      sender.clear();
+      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), user.email());
+      gApi.accounts().id(user.username()).addSshKey(newKey);
+      info = gApi.accounts().id(user.username()).listSshKeys();
+      assertThat(info).hasSize(2);
+      assertSequenceNumbers(info);
+      accountIndexedCounter.assertReindexOf(user);
 
-    assertThat(sender.getMessages()).hasSize(1);
-    Message message = sender.getMessages().get(0);
-    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
-    assertThat(message.body()).contains("new SSH keys have been added");
+      assertThat(sender.getMessages()).hasSize(1);
+      Message message = sender.getMessages().get(0);
+      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertThat(message.body()).contains("new SSH keys have been added");
 
-    // Delete key
-    sender.clear();
-    gApi.accounts().id(user.username()).deleteSshKey(1);
-    info = gApi.accounts().id(user.username()).listSshKeys();
-    assertThat(info).hasSize(1);
-    accountIndexedCounter.assertReindexOf(user);
+      // Delete key
+      sender.clear();
+      gApi.accounts().id(user.username()).deleteSshKey(1);
+      info = gApi.accounts().id(user.username()).listSshKeys();
+      assertThat(info).hasSize(1);
+      accountIndexedCounter.assertReindexOf(user);
 
-    assertThat(sender.getMessages()).hasSize(1);
-    message = sender.getMessages().get(0);
-    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
-    assertThat(message.body()).contains("SSH keys have been deleted");
+      assertThat(sender.getMessages()).hasSize(1);
+      message = sender.getMessages().get(0);
+      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertThat(message.body()).contains("SSH keys have been deleted");
+    }
   }
 
   @Test
@@ -2414,40 +2024,47 @@
   public void userCannotAddSshKeyToOtherAccount() throws Exception {
     String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).addSshKey(newKey);
+    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
   }
 
   @Test
   @UseSsh
   public void userCannotDeleteSshKeyOfOtherAccount() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    gApi.accounts().id(admin.username()).deleteSshKey(0);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.accounts().id(admin.username()).deleteSshKey(0));
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
   @Test
   public void reindexPermissions() throws Exception {
-    // admin can reindex any account
-    requestScopeOperations.setApiUser(admin.id());
-    gApi.accounts().id(user.username()).index();
-    accountIndexedCounter.assertReindexOf(user);
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      // admin can reindex any account
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.accounts().id(user.username()).index();
+      accountIndexedCounter.assertReindexOf(user);
 
-    // user can reindex own account
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().index();
-    accountIndexedCounter.assertReindexOf(user);
+      // user can reindex own account
+      requestScopeOperations.setApiUser(user.id());
+      gApi.accounts().self().index();
+      accountIndexedCounter.assertReindexOf(user);
 
-    // user cannot reindex any account
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.username()).index();
+      // user cannot reindex any account
+      AuthException thrown =
+          assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).index());
+      assertThat(thrown).hasMessageThat().contains("modify account not permitted");
+    }
   }
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     // Create an account with a preferred email.
@@ -2502,22 +2119,21 @@
   @Test
   public void checkMetaId() throws Exception {
     // metaId is set when account is loaded
-    assertThat(accounts.get(admin.id()).get().getAccount().getMetaId())
-        .isEqualTo(getMetaId(admin.id()));
+    assertThat(accounts.get(admin.id()).get().account().metaId()).isEqualTo(getMetaId(admin.id()));
 
     // metaId is set when account is created
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
-    assertThat(accountState.getAccount().getMetaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.account().metaId()).isEqualTo(getMetaId(accountId));
 
     // metaId is set when account is updated
     Optional<AccountState> updatedAccountState =
         au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
     assertThat(updatedAccountState).isPresent();
-    Account updatedAccount = updatedAccountState.get().getAccount();
-    assertThat(accountState.getAccount().getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
-    assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
+    Account updatedAccount = updatedAccountState.get().account();
+    assertThat(accountState.account().metaId()).isNotEqualTo(updatedAccount.metaId());
+    assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
   }
 
   private EmailInput newEmailInput(String email, boolean noConfirmation) {
@@ -2571,12 +2187,9 @@
             "@", "@foo", "-", "-foo", "_", "_foo", "!", "+", "{", "}", "*", "%", "#", "$", "&", "’",
             "^", "=", "~");
     for (String name : invalidNames) {
-      try {
-        gApi.accounts().create(name);
-        fail(String.format("Expected BadRequestException for username [%s]", name));
-      } catch (BadRequestException e) {
-        assertThat(e).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
-      }
+      BadRequestException thrown =
+          assertThrows(BadRequestException.class, () -> gApi.accounts().create(name));
+      assertThat(thrown).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
     }
   }
 
@@ -2635,7 +2248,11 @@
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg,
+                retryMetrics,
+                null,
+                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2661,9 +2278,9 @@
     assertThat(doneBgUpdate.get()).isTrue();
 
     assertThat(updatedAccountState).isPresent();
-    Account updatedAccount = updatedAccountState.get().getAccount();
-    assertThat(updatedAccount.getStatus()).isEqualTo(status);
-    assertThat(updatedAccount.getFullName()).isEqualTo(fullName);
+    Account updatedAccount = updatedAccountState.get().account();
+    assertThat(updatedAccount.status()).isEqualTo(status);
+    assertThat(updatedAccount.fullName()).isEqualTo(fullName);
 
     accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isEqualTo(status);
@@ -2688,6 +2305,7 @@
                 cfg,
                 retryMetrics,
                 null,
+                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
                 r ->
                     r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
                         .withBlockStrategy(noSleepBlockStrategy)),
@@ -2712,17 +2330,14 @@
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
-    try {
-      update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName));
-      fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Ignore, expected
-    }
+    assertThrows(
+        LockFailureException.class,
+        () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
     assertThat(bgCounter.get()).isEqualTo(status.size());
 
-    Account updatedAccount = accounts.get(admin.id()).get().getAccount();
-    assertThat(updatedAccount.getStatus()).isEqualTo(Iterables.getLast(status));
-    assertThat(updatedAccount.getFullName()).isEqualTo(admin.fullName());
+    Account updatedAccount = accounts.get(admin.id()).get().account();
+    assertThat(updatedAccount.status()).isEqualTo(Iterables.getLast(status));
+    assertThat(updatedAccount.fullName()).isEqualTo(admin.fullName());
 
     accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isEqualTo(Iterables.getLast(status));
@@ -2745,7 +2360,11 @@
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg,
+                retryMetrics,
+                null,
+                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2768,12 +2387,12 @@
             "Set Status",
             admin.id(),
             (a, u) -> {
-              if ("A-1".equals(a.getAccount().getStatus())) {
+              if ("A-1".equals(a.account().status())) {
                 bgCounterA1.getAndIncrement();
                 u.setStatus("B-1");
               }
 
-              if ("A-2".equals(a.getAccount().getStatus())) {
+              if ("A-2".equals(a.account().status())) {
                 bgCounterA2.getAndIncrement();
                 u.setStatus("B-2");
               }
@@ -2783,16 +2402,19 @@
     assertThat(bgCounterA2.get()).isEqualTo(1);
 
     assertThat(updatedAccountState).isPresent();
-    assertThat(updatedAccountState.get().getAccount().getStatus()).isEqualTo("B-2");
-    assertThat(accounts.get(admin.id()).get().getAccount().getStatus()).isEqualTo("B-2");
+    assertThat(updatedAccountState.get().account().status()).isEqualTo("B-2");
+    assertThat(accounts.get(admin.id()).get().account().status()).isEqualTo("B-2");
     assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("B-2");
   }
 
   @Test
   public void atomicReadMofifyWriteExternalIds() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId extIdA1 = ExternalId.create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
@@ -2811,7 +2433,11 @@
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg,
+                retryMetrics,
+                null,
+                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2843,12 +2469,12 @@
             "Update External ID",
             accountId,
             (a, u) -> {
-              if (a.getExternalIds().contains(extIdA1)) {
+              if (a.externalIds().contains(extIdA1)) {
                 bgCounterA1.getAndIncrement();
                 u.replaceExternalId(extIdA1, extIdB1);
               }
 
-              if (a.getExternalIds().contains(extIdA2)) {
+              if (a.externalIds().contains(extIdA2)) {
                 bgCounterA2.getAndIncrement();
                 u.replaceExternalId(extIdA2, extIdB2);
               }
@@ -2858,8 +2484,8 @@
     assertThat(bgCounterA2.get()).isEqualTo(1);
 
     assertThat(updatedAccount).isPresent();
-    assertThat(updatedAccount.get().getExternalIds()).containsExactly(extIdB2);
-    assertThat(accounts.get(accountId).get().getExternalIds()).containsExactly(extIdB2);
+    assertThat(updatedAccount.get().externalIds()).containsExactly(extIdB2);
+    assertThat(accounts.get(accountId).get().externalIds()).containsExactly(extIdB2);
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
@@ -2871,7 +2497,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    Account.Id accountId = Account.id(accountInfo._accountId);
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
 
     // Manually updating the user ref makes the index document stale.
@@ -2948,9 +2574,9 @@
   }
 
   @Test
+  @UseClockStep
   public void deleteAllDraftComments() throws Exception {
     try {
-      TestTimeUtil.resetWithClockStep(1, SECONDS);
       Project.NameKey project2 = projectOperations.newProject().create();
       PushOneCommit.Result r1 = createChange();
 
@@ -3035,12 +2661,14 @@
       requestScopeOperations.setApiUser(user.id());
       createDraft(r, PushOneCommit.FILE_NAME, "draft");
       requestScopeOperations.setApiUser(admin.id());
-      try {
-        gApi.accounts().id(user.id().get()).deleteDraftComments(new DeleteDraftCommentsInput());
-        assert_().fail("expected AuthException");
-      } catch (AuthException e) {
-        assertThat(e).hasMessageThat().isEqualTo("Cannot delete drafts of other user");
-      }
+      AuthException thrown =
+          assertThrows(
+              AuthException.class,
+              () ->
+                  gApi.accounts()
+                      .id(user.id().get())
+                      .deleteDraftComments(new DeleteDraftCommentsInput()));
+      assertThat(thrown).hasMessageThat().isEqualTo("Cannot delete drafts of other user");
     } finally {
       cleanUpDrafts();
     }
@@ -3049,7 +2677,7 @@
   @Test
   public void deleteDraftCommentsSkipsInvisibleChanges() throws Exception {
     try {
-      createBranch(new Branch.NameKey(project, "secret"));
+      createBranch(BranchNameKey.create(project, "secret"));
       PushOneCommit.Result r1 = createChange();
       PushOneCommit.Result r2 = createChange("refs/for/secret");
 
@@ -3059,14 +2687,22 @@
       assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
       assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
 
-      block(project, "refs/heads/secret", Permission.READ, REGISTERED_USERS);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+          .update();
       List<DeletedDraftCommentInfo> result =
           gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
       assertThat(result).hasSize(1);
       assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
       assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
 
-      removePermission(project, "refs/heads/secret", Permission.READ);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.READ).ref("refs/heads/secret"))
+          .update();
       assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
       // Draft still exists since change wasn't visible when drafts where deleted.
       assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
@@ -3097,22 +2733,23 @@
   @Test
   public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).generateHttpPassword();
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).generateHttpPassword());
   }
 
   @Test
   public void userCannotExplicitlySetHttpPassword() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().self().setHttpPassword("my-new-password");
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().self().setHttpPassword("my-new-password"));
   }
 
   @Test
   public void userCannotExplicitlySetHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setHttpPassword("my-new-password");
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setHttpPassword("my-new-password"));
   }
 
   @Test
@@ -3127,8 +2764,8 @@
   @Test
   public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setHttpPassword(null);
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).setHttpPassword(null));
   }
 
   @Test
@@ -3156,9 +2793,11 @@
     requestScopeOperations.setApiUser(admin.id());
     int userId = accountCreator.create().id().get();
     assertThat(gApi.accounts().id(userId).get().username).isNull();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("username");
-    gApi.accounts().id(userId).generateHttpPassword();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(userId).generateHttpPassword());
+    assertThat(thrown).hasMessageThat().contains("username");
   }
 
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
@@ -3185,12 +2824,9 @@
 
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<GroupInfo, String>() {
-          @Override
-          public boolean apply(GroupInfo actualGroup, String expectedName) {
-            String groupName = actualGroup == null ? null : actualGroup.name;
-            return Objects.equals(groupName, expectedName);
-          }
+        (actualGroup, expectedName) -> {
+          String groupName = actualGroup == null ? null : actualGroup.name;
+          return Objects.equals(groupName, expectedName);
         },
         "has name");
   }
@@ -3239,8 +2875,8 @@
     // Check via API.
     FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
     Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
-    assertThat(keyMap.keySet())
-        .named("keys returned by listGpgKeys()")
+    assertWithMessage("keys returned by listGpgKeys()")
+        .that(keyMap.keySet())
         .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
 
     for (TestKey key : expected) {
@@ -3262,7 +2898,9 @@
         externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
             .collect(toSet());
-    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
+    assertWithMessage("external IDs in database")
+        .that(actualFps)
+        .containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
     for (TestKey key : expected) {
@@ -3272,31 +2910,35 @@
 
   private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
     String id = expected.getKeyIdString();
-    assertThat(actual.id).named(id).isEqualTo(id);
-    assertThat(actual.fingerprint)
-        .named(id)
+    assertWithMessage(id).that(actual.id).isEqualTo(id);
+    assertWithMessage(id)
+        .that(actual.fingerprint)
         .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
     List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
-    assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
+    assertWithMessage(id).that(actual.userIds).containsExactlyElementsIn(userIds);
     String key = actual.key;
-    assertThat(key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
-    assertThat(key).named(id).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(key).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(key).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
     assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
     assertThat(actual.problems).isEmpty();
   }
 
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
-    requireNonNull(email);
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Add Email",
-            account.id(),
-            u ->
-                u.addExternalId(
-                    ExternalId.createWithEmail(name("test"), email, account.id(), email)));
-    accountIndexedCounter.assertReindexOf(account);
-    requestScopeOperations.setApiUser(account.id());
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      requireNonNull(email);
+      accountsUpdateProvider
+          .get()
+          .update(
+              "Add Email",
+              account.id(),
+              u ->
+                  u.addExternalId(
+                      ExternalId.createWithEmail(name("test"), email, account.id(), email)));
+      accountIndexedCounter.assertReindexOf(account);
+      requestScopeOperations.setApiUser(account.id());
+    }
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
@@ -3304,12 +2946,16 @@
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
-    Map<String, GpgKeyInfo> gpgKeys =
-        gApi.accounts()
-            .id(account.username())
-            .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-    accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
-    return gpgKeys;
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      Map<String, GpgKeyInfo> gpgKeys =
+          gApi.accounts()
+              .id(account.username())
+              .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+      accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
+      return gpgKeys;
+    }
   }
 
   private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
@@ -3337,67 +2983,6 @@
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
   }
 
-  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
-    Config ac = new Config();
-    try (TreeWalk tw =
-        TreeWalk.forPath(
-            allUsersRepo.getRepository(),
-            AccountProperties.ACCOUNT_CONFIG,
-            getHead(allUsersRepo.getRepository(), "HEAD").getTree())) {
-      assertThat(tw).isNotNull();
-      ac.fromText(
-          new String(
-              allUsersRepo
-                  .getRevWalk()
-                  .getObjectReader()
-                  .open(tw.getObjectId(0), OBJ_BLOB)
-                  .getBytes(),
-              UTF_8));
-    }
-    return ac;
-  }
-
-  /** Checks if an account is indexed the correct number of times. */
-  private static class AccountIndexedCounter implements AccountIndexedListener {
-    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
-
-    @Override
-    public void onAccountIndexed(int id) {
-      countsByAccount.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByAccount.clear();
-    }
-
-    long getCount(Account.Id accountId) {
-      return countsByAccount.get(accountId.get());
-    }
-
-    void assertReindexOf(TestAccount testAccount) {
-      assertReindexOf(testAccount, 1);
-    }
-
-    void assertReindexOf(AccountInfo accountInfo) {
-      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
-    }
-
-    void assertReindexOf(TestAccount testAccount, int expectedCount) {
-      assertThat(getCount(testAccount.id())).isEqualTo(expectedCount);
-      assertThat(countsByAccount).hasSize(1);
-      clear();
-    }
-
-    void assertReindexOf(Account.Id accountId, int expectedCount) {
-      assertThat(getCount(accountId)).isEqualTo(expectedCount);
-      countsByAccount.remove(accountId.get());
-    }
-
-    void assertNoReindex() {
-      assertThat(countsByAccount).isEmpty();
-    }
-  }
-
   private static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
@@ -3418,23 +3003,17 @@
       countsByProjectRefs.clear();
     }
 
-    long getCount(String projectRef) {
-      return countsByProjectRefs.get(projectRef);
-    }
-
     void assertRefUpdateFor(String... projectRefs) {
-      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
+      Map<String, Long> expectedRefUpdateCounts = new HashMap<>();
       for (String projectRef : projectRefs) {
-        expectedRefUpdateCounts.put(projectRef, 1);
+        expectedRefUpdateCounts.put(projectRef, 1L);
       }
       assertRefUpdateFor(expectedRefUpdateCounts);
     }
 
-    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
-      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
-        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
-      }
-      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
+    void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
+      assertThat(countsByProjectRefs.asMap())
+          .containsExactlyEntriesIn(expectedProjectRefUpdateCounts);
       clear();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index 60a61d1..72a8264 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -17,10 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
@@ -66,7 +66,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).account().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -82,7 +82,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).account().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -91,10 +91,10 @@
     loadAccountToCache(accountId);
     String status = "ooo";
     updateAccountWithoutCacheOrIndex(accountId, newAccountUpdate().setStatus(status).build());
-    assertThat(accountCache.get(accountId).get().getAccount().getStatus()).isNull();
+    assertThat(accountCache.get(accountId).get().account().status()).isNull();
 
     accountIndexer.index(accountId);
-    assertThat(accountCache.get(accountId).get().getAccount().getStatus()).isEqualTo(status);
+    assertThat(accountCache.get(accountId).get().account().status()).isEqualTo(status);
   }
 
   @Test
@@ -109,7 +109,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).account().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -136,7 +136,7 @@
 
   private Account.Id createAccount(String name) throws RestApiException {
     AccountInfo account = gApi.accounts().create(name).get();
-    return new Account.Id(account._accountId);
+    return Account.id(account._accountId);
   }
 
   private void reloadAccountToCache(Account.Id accountId) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index a277b88..25617d4 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -14,15 +14,18 @@
 
 package com.google.gerrit.acceptance.api.accounts;
 
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -125,7 +128,7 @@
   @Test
   public void authenticateWithEmail() throws Exception {
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
     accountsUpdate.insert(
         "Create Test Account",
@@ -140,7 +143,7 @@
   @Test
   public void authenticateWithUsername() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -155,7 +158,7 @@
   @Test
   public void authenticateWithExternalUser() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -171,7 +174,7 @@
   public void authenticateWithUsernameAndUpdateEmail() throws Exception {
     String username = "foo";
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -192,14 +195,14 @@
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().getPreferredEmail()).isEqualTo(newEmail);
+    assertThat(accountState.get().account().preferredEmail()).isEqualTo(newEmail);
   }
 
   @Test
   public void authenticateWithUsernameAndUpdateDisplayName() throws Exception {
     String username = "foo";
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -217,7 +220,7 @@
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().getFullName()).isEqualTo(newName);
+    assertThat(accountState.get().account().fullName()).isEqualTo(newName);
   }
 
   @Test
@@ -227,7 +230,7 @@
     assertNoSuchExternalIds(gerritExtIdKey);
 
     // Create orphaned SCHEME_GERRIT external ID.
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId gerritExtId = ExternalId.create(gerritExtIdKey, accountId);
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
@@ -237,15 +240,15 @@
     }
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account not found");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account not found");
   }
 
   @Test
   public void cannotAuthenticateWithInactiveAccount() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -253,16 +256,16 @@
         u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
   public void cannotActivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -272,9 +275,9 @@
     AuthRequest who = AuthRequest.forUser(username);
     who.setActive(true);
     who.setAuthProvidesAccountActiveStatus(true);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
@@ -282,7 +285,7 @@
   public void activateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -296,14 +299,14 @@
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().isActive()).isTrue();
+    assertThat(accountState.get().account().isActive()).isTrue();
   }
 
   @Test
   public void cannotDeactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -317,7 +320,7 @@
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().isActive()).isTrue();
+    assertThat(accountState.get().account().isActive()).isTrue();
   }
 
   @Test
@@ -325,7 +328,7 @@
   public void deactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -335,16 +338,13 @@
     AuthRequest who = AuthRequest.forUser(username);
     who.setActive(false);
     who.setAuthProvidesAccountActiveStatus(true);
-    try {
-      accountManager.authenticate(who);
-      fail("Expected AccountException");
-    } catch (AccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Authentication error, account inactive");
-    }
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().isEqualTo("Authentication error, account inactive");
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().isActive()).isFalse();
+    assertThat(accountState.get().account().isActive()).isFalse();
   }
 
   @Test
@@ -353,7 +353,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -363,9 +363,11 @@
     // Try to authenticate with this email to create a new account with a SCHEME_MAILTO external ID.
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -374,7 +376,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -385,9 +387,11 @@
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forUser("bar");
     who.setEmailAddress(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -397,7 +401,7 @@
 
     // Create an account with a SCHEME_GERRIT external ID and an email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -407,7 +411,7 @@
                 .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
 
     // Create another account with an SCHEME_EXTERNAL external ID that occupies the new email.
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, "bar");
     accountsUpdate.insert(
         "Create Test Account",
@@ -418,12 +422,11 @@
     // Expect that this fails because the new email is already assigned to the other account.
     AuthRequest who = AuthRequest.forUser(username);
     who.setEmailAddress(newEmail);
-    try {
-      accountManager.authenticate(who);
-      fail("Expected AccountException");
-    } catch (AccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Email 'bar@example.com' in use by another account");
-    }
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Email 'bar@example.com' in use by another account");
 
     // Verify that the email in the external ID was not updated.
     Optional<ExternalId> gerritExtId = externalIds.get(gerritExtIdKey);
@@ -433,7 +436,7 @@
     // Verify that the preferred email was not updated.
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().getPreferredEmail()).isEqualTo(email);
+    assertThat(accountState.get().account().preferredEmail()).isEqualTo(email);
   }
 
   @Test
@@ -443,7 +446,7 @@
     // Create an account with a SCHEME_GERRIT external ID
     String username = "foo";
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
@@ -465,20 +468,20 @@
 
     // Verify that the account external ids with scheme 'mailto:' contains the email
     AccountState account = accounts.get(authResult.getAccountId()).get();
-    ImmutableSet<ExternalId> accountExternalIds = account.getExternalIds(ExternalId.SCHEME_MAILTO);
+    ImmutableSet<ExternalId> accountExternalIds = account.externalIds();
     assertThat(accountExternalIds).isNotEmpty();
     Set<String> emails = ExternalId.getEmails(accountExternalIds).collect(toSet());
     assertThat(emails).contains(email);
 
     // Verify the preferred email
-    assertThat(account.getAccount().getPreferredEmail()).isEqualTo(email);
+    assertThat(account.account().preferredEmail()).isEqualTo(email);
   }
 
   @Test
   public void linkNewExternalId() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -502,7 +505,7 @@
   public void updateExternalIdOnLink() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -525,7 +528,7 @@
   public void cannotLinkExternalIdThatIsAlreadyUsed() throws Exception {
     // Create an account with a SCHEME_EXTERNAL external ID
     String username1 = "foo";
-    Account.Id accountId1 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId1 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey1 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username1);
     accountsUpdate.insert(
         "Create Test Account",
@@ -534,7 +537,7 @@
 
     // Create another account with a SCHEME_EXTERNAL external ID
     String username2 = "bar";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey2 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -544,9 +547,11 @@
     // Try to link external ID of the first account to the second account.
     // Expect that this fails because the external ID is already assigned to the first account.
     AuthRequest who = AuthRequest.forExternalUser(username1);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Identity 'external:foo' in use by another account");
-    accountManager.link(accountId2, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'external:foo' in use by another account");
   }
 
   @Test
@@ -555,7 +560,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -564,7 +569,7 @@
 
     // Create another account with a SCHEME_GERRIT external ID and no email
     String username2 = "foo";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -574,9 +579,11 @@
     // Try to link the email to the second account (via a new MAILTO external ID) and expect that
     // this fails because the email is already assigned to the first account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.link(accountId2, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -585,7 +592,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -600,7 +607,10 @@
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
     for (ExternalId.Key extIdKey : extIdKeys) {
-      assertThat(externalIds.get(extIdKey)).named(extIdKey.get()).isEmpty();
+      assertWithMessage(extIdKey.get())
+          .about(optionals())
+          .that(externalIds.get(extIdKey))
+          .isEmpty();
     }
   }
 
@@ -621,13 +631,15 @@
       @Nullable String expectedEmail)
       throws Exception {
     Optional<ExternalId> extId = externalIds.get(extIdKey);
-    assertThat(extId).named(extIdKey.get()).isPresent();
+    assertWithMessage(extIdKey.get()).about(optionals()).that(extId).isPresent();
     if (expectedAccountId != null) {
-      assertThat(extId.get().accountId())
-          .named("account ID of " + extIdKey.get())
+      assertWithMessage("account ID of " + extIdKey.get())
+          .that(extId.get().accountId())
           .isEqualTo(expectedAccountId);
     }
-    assertThat(extId.get().email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
+    assertWithMessage("email of " + extIdKey.get())
+        .that(extId.get().email())
+        .isEqualTo(expectedEmail);
   }
 
   private void assertAuthResultForNewAccount(
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index a4a5745..5550d98 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,19 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -44,21 +47,17 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
 import org.junit.Before;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
+@UseClockStep
 public class AgreementsIT extends AbstractDaemonTest {
   private ContributorAgreement caAutoVerify;
   private ContributorAgreement caNoAutoVerify;
@@ -81,7 +80,7 @@
     AccountGroup.UUID g = groupOperations.newGroup().name(name).create();
     GroupApi groupApi = gApi.groups().id(g.get());
     groupApi.description("CLA test group");
-    InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
+    InternalGroup caGroup = group(AccountGroup.uuid(groupApi.detail().id));
     GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
     PermissionRule rule = new PermissionRule(groupRef);
     rule.setAction(PermissionRule.Action.ALLOW);
@@ -111,16 +110,6 @@
     return cfg;
   }
 
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
   @Before
   public void setUp() throws Exception {
     caAutoVerify = configureContributorAgreement(true);
@@ -145,17 +134,21 @@
   @Test
   public void signNonExistingAgreement() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("contributor agreement not found");
-    gApi.accounts().self().signAgreement("does-not-exist");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.accounts().self().signAgreement("does-not-exist"));
+    assertThat(thrown).hasMessageThat().contains("contributor agreement not found");
   }
 
   @Test
   public void signAgreementNoAutoVerify() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().signAgreement(caNoAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("cannot enter a non-autoVerify agreement");
   }
 
   @Test
@@ -188,33 +181,40 @@
   public void signAgreementAsOtherUser() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     assertThat(gApi.accounts().self().get().name).isNotEqualTo("admin");
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to enter contributor agreement");
-    gApi.accounts().id("admin").signAgreement(caAutoVerify.getName());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id("admin").signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("not allowed to enter contributor agreement");
   }
 
   @Test
   public void signAgreementAnonymous() throws Exception {
     requestScopeOperations.setApiUserAnonymous();
-    exception.expect(AuthException.class);
-    exception.expectMessage("Authentication required");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
   @Test
   public void agreementsDisabledSign() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
   public void agreementsDisabledList() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().listAgreements();
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class, () -> gApi.accounts().self().listAgreements());
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
@@ -233,9 +233,9 @@
     // Revert is not allowed when CLA is required but not signed
     requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    exception.expect(AuthException.class);
-    exception.expectMessage("Contributor Agreement");
-    gApi.changes().id(change.changeId).revert();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(change.changeId).revert());
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
   }
 
   @Test
@@ -287,9 +287,10 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = dest.ref;
     in.message = change.subject;
-    exception.expect(AuthException.class);
-    exception.expectMessage("Contributor Agreement");
-    gApi.changes().id(change.changeId).current().cherryPick(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change.changeId).current().cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
   }
 
   @Test
@@ -302,12 +303,9 @@
 
     // Create a change is not allowed when CLA is required but not signed
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    try {
-      gApi.changes().create(newChangeInput());
-      fail("Expected AuthException");
-    } catch (AuthException e) {
-      assertThat(e.getMessage()).contains("Contributor Agreement");
-    }
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().create(newChangeInput()));
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
 
     // Sign the agreement
     gApi.accounts().self().signAgreement(caAutoVerify.getName());
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index c905d3f..6717fb7 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -16,8 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -26,23 +29,18 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class GeneralPreferencesIT extends AbstractDaemonTest {
-  @Inject private DynamicMap<DownloadScheme> downloadSchemes;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private TestAccount user42;
 
@@ -63,15 +61,13 @@
             new MenuItem("Edits", "#/q/has:edit", null),
             new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
             new MenuItem("Starred Changes", "#/q/is:starred", null),
-            new MenuItem("Groups", "#/groups/self", null));
+            new MenuItem("Groups", "#/settings/#Groups", null));
     assertThat(o.changeTable).isEmpty();
 
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
 
     // change all default values
     i.changesPerPage *= -1;
-    i.showSiteHeader ^= true;
-    i.useFlashClipboard ^= true;
     i.dateFormat = DateFormat.US;
     i.timeFormat = TimeFormat.HHMM_24;
     i.emailStrategy = EmailStrategy.DISABLED;
@@ -84,7 +80,6 @@
     i.legacycidInChangeTable ^= true;
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
-    i.reviewCategoryStrategy = ReviewCategoryStrategy.ABBREV;
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
@@ -155,9 +150,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(null, "url"));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name for menu item is required");
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("name for menu item is required");
   }
 
   @Test
@@ -166,9 +163,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", null));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("URL for menu item is required");
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("URL for menu item is required");
   }
 
   @Test
@@ -186,18 +185,20 @@
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
     i.downloadScheme = "foo";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Unsupported download scheme: " + i.downloadScheme);
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Unsupported download scheme: " + i.downloadScheme);
   }
 
   @Test
   public void setDownloadScheme() throws Exception {
     String schemeName = "foo";
-    RegistrationHandle registrationHandle =
-        ((PrivateInternals_DynamicMapImpl<DownloadScheme>) downloadSchemes)
-            .put("myPlugin", schemeName, Providers.of(new TestDownloadScheme()));
-    try {
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestDownloadScheme(), schemeName)) {
       GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
       i.downloadScheme = schemeName;
 
@@ -206,8 +207,6 @@
 
       o = gApi.accounts().id(user42.id().toString()).getPreferences();
       assertThat(o.downloadScheme).isEqualTo(schemeName);
-    } finally {
-      registrationHandle.remove();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 925c66a..8aebc69 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -26,13 +27,15 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.AbandonUtil;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
@@ -46,8 +49,9 @@
 
 public class AbandonIT extends AbstractDaemonTest {
   @Inject private AbandonUtil abandonUtil;
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeCleanupConfig cleanupConfig;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void abandon() throws Exception {
@@ -59,9 +63,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).abandon();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -89,24 +93,29 @@
     String project2Name = name("Project2");
     gApi.projects().create(project1Name);
     gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+    TestRepository<InMemoryRepository> project1 = cloneProject(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     CurrentUser user = atrScope.get().getUser();
     PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
     PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
     List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    batchAbandon.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                batchAbandon.batchAbandon(
+                    batchUpdateFactory, Project.nameKey(project1Name), user, list));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
   }
 
   @Test
+  @UseClockStep
   @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
   public void abandonInactiveOpenChanges() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-
     // create 2 changes which will be abandoned ...
     int id1 = createChange().getChange().getId().get();
     int id2 = createChange().getChange().getId().get();
@@ -157,9 +166,9 @@
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("abandon not permitted");
-    gApi.changes().id(changeId).abandon();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("abandon not permitted");
   }
 
   @Test
@@ -167,7 +176,11 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
@@ -188,9 +201,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is new");
-    gApi.changes().id(changeId).restore();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("change is new");
   }
 
   @Test
@@ -201,9 +214,9 @@
     gApi.changes().id(changeId).abandon();
     requestScopeOperations.setApiUser(user.id());
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-    exception.expect(AuthException.class);
-    exception.expectMessage("restore not permitted");
-    gApi.changes().id(changeId).restore();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("restore not permitted");
   }
 
   private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index fe97688..59e0a68 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -15,12 +15,21 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -38,20 +47,23 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.CacheStatsSubject.assertThat;
+import static com.google.gerrit.truth.CacheStatsSubject.cloneStats;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -60,20 +72,33 @@
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -87,7 +112,6 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
-import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -98,7 +122,6 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -119,12 +142,8 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -135,22 +154,21 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.IntraLineDiff;
+import com.google.gerrit.server.patch.IntraLineDiffKey;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
@@ -159,9 +177,9 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.name.Named;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -184,49 +202,31 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
+@UseTimezone(timezone = "US/Eastern")
 public class ChangeIT extends AbstractDaemonTest {
-  private String systemTimeZone;
 
   @Inject private AccountOperations accountOperations;
   @Inject private ChangeIndexCollection changeIndexCollection;
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   @Inject private GroupOperations groupOperations;
   @Inject private IndexConfig indexConfig;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
-  private ChangeIndexedCounter changeIndexedCounter;
-  private RegistrationHandle changeIndexedCounterHandle;
+  @Inject
+  @Named("diff")
+  private Cache<PatchListKey, PatchList> fileCache;
 
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-  }
+  @Inject
+  @Named("diff_intraline")
+  private Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
 
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Before
-  public void addChangeIndexedCounter() {
-    changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
-  }
-
-  @After
-  public void removeChangeIndexedCounter() {
-    if (changeIndexedCounterHandle != null) {
-      changeIndexedCounterHandle.remove();
-    }
-  }
+  @Inject
+  @Named("diff_summary")
+  private Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
 
   @Test
   public void get() throws Exception {
@@ -252,6 +252,48 @@
   }
 
   @Test
+  public void diffStatShouldComputeInsertionsAndDeletions() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    ChangeInfo change = gApi.changes().id(triplet).get();
+    assertThat(change.insertions).isNotNull();
+    assertThat(change.deletions).isNotNull();
+  }
+
+  @Test
+  public void diffStatShouldSkipInsertionsAndDeletions() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    ChangeInfo change =
+        gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+    assertThat(change.insertions).isNull();
+    assertThat(change.deletions).isNull();
+  }
+
+  @Test
+  public void skipDiffstatOptionAvoidsAllDiffComputations() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    CacheStats startPatch = cloneStats(fileCache.stats());
+    CacheStats startIntra = cloneStats(intraCache.stats());
+    CacheStats startSummary = cloneStats(diffSummaryCache.stats());
+    gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+
+    assertThat(fileCache.stats()).since(startPatch).hasMissCount(0);
+    assertThat(fileCache.stats()).since(startPatch).hasHitCount(0);
+    assertThat(intraCache.stats()).since(startIntra).hasMissCount(0);
+    assertThat(intraCache.stats()).since(startIntra).hasHitCount(0);
+    assertThat(diffSummaryCache.stats()).since(startSummary).hasMissCount(0);
+    assertThat(diffSummaryCache.stats()).since(startSummary).hasHitCount(0);
+  }
+
+  @Test
   public void skipMergeable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -277,9 +319,9 @@
     String changeId = rwip.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(changeId).setWorkInProgress();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setWorkInProgress());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -300,7 +342,11 @@
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setWorkInProgress();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
@@ -323,9 +369,9 @@
     gApi.changes().id(changeId).setWorkInProgress();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(changeId).setReadyForReview();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setReadyForReview());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -348,7 +394,11 @@
     gApi.changes().id(changeId).setWorkInProgress();
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setReadyForReview();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
@@ -367,7 +417,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);
@@ -415,14 +465,13 @@
             .reviewer("byemail2@example.com")
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
-    ReviewResult result = gApi.changes().id(changeId).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(changeId).current().review(in);
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
         ais -> ais.stream().map(ai -> ai.email).collect(toSet());
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(
-            admin.email(), email1, email2, "byemail1@example.com", "byemail2@example.com");
+        .containsExactly(email1, email2, "byemail1@example.com", "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email3, email4, "byemail3@example.com", "byemail4@example.com");
     assertThat(info.pendingReviewers.get(REMOVED)).isNull();
@@ -434,7 +483,7 @@
     gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email(), email2, "byemail2@example.com");
+        .containsExactly(email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
@@ -442,10 +491,10 @@
 
     // "Undo" a removal.
     in = ReviewInput.noScore().reviewer(email1);
-    gApi.changes().id(changeId).revision("current").review(in);
+    gApi.changes().id(changeId).current().review(in);
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email(), email1, email2, "byemail2@example.com");
+        .containsExactly(email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
@@ -456,7 +505,7 @@
     info = gApi.changes().id(changeId).get();
     assertThat(info.pendingReviewers).isEmpty();
     assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
-        .containsExactly(admin.email(), email1, email2, "byemail2@example.com");
+        .containsExactly(email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.reviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(info.reviewers.get(REMOVED)).isNull();
@@ -507,12 +556,14 @@
     String refactor = "Needs some refactoring";
     String ptal = "PTAL";
 
-    grant(
-        project,
-        "refs/heads/master",
-        Permission.TOGGLE_WORK_IN_PROGRESS_STATE,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).setWorkInProgress(refactor);
@@ -538,7 +589,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.ready).isTrue();
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -551,7 +602,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.ready).isNull();
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -568,7 +619,7 @@
             .reviewer(user.email())
             .label("Code-Review", 1)
             .setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    gApi.changes().id(r.getChangeId()).current().review(in);
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
     assertThat(info.workInProgress).isTrue();
@@ -583,7 +634,7 @@
     ReviewInput in = ReviewInput.noScore();
     in.ready = true;
     in.workInProgress = true;
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
   }
 
@@ -622,21 +673,24 @@
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void reviewWithWorkInProgressByNonOwnerWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    grant(
-        project,
-        "refs/heads/master",
-        Permission.TOGGLE_WORK_IN_PROGRESS_STATE,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(in);
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -648,9 +702,10 @@
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setReady(true);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -674,105 +729,9 @@
     PushOneCommit.Result r2 = push2.to("refs/for/other");
     assertThat(r2.getChangeId()).isEqualTo(changeId);
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Multiple changes found for " + changeId);
-    gApi.changes().id(changeId).get();
-  }
-
-  @Test
-  public void revert() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
-
-    // expected messages on source change:
-    // 1. Uploaded patch set 1.
-    // 2. Patch Set 1: Code-Review+2
-    // 3. Change has been successfully merged by Administrator
-    // 4. Patch Set 1: Reverted
-    List<ChangeMessageInfo> sourceMessages =
-        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
-    assertThat(sourceMessages).hasSize(4);
-    String expectedMessage =
-        String.format("Created a revert of this change as %s", revertChange.changeId);
-    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
-
-    assertThat(revertChange.messages).hasSize(1);
-    assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number);
-  }
-
-  @Test
-  public void revertNotifications() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    sender.clear();
-    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(2);
-    assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
-    assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
-  }
-
-  @Test
-  public void suppressRevertNotifications() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    RevertInput revertInput = new RevertInput();
-    revertInput.notify = NotifyHandling.NONE;
-
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void revertPreservesReviewersAndCcs() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    ReviewInput in = ReviewInput.approve();
-    in.reviewer(user.email());
-    in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
-    // Add user as reviewer that will create the revert
-    in.reviewer(accountCreator.admin2().email());
-
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    // expect both the original reviewers and CCs to be preserved
-    // original owner should be added as reviewer, user requesting the revert (new owner) removed
-    requestScopeOperations.setApiUser(accountCreator.admin2().id());
-    Map<ReviewerState, Collection<AccountInfo>> result =
-        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
-    assertThat(result).containsKey(ReviewerState.REVIEWER);
-
-    List<Integer> reviewers =
-        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    assertThat(result).containsKey(ReviewerState.CC);
-    List<Integer> ccs =
-        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-    assertThat(ccs).containsExactly(accountCreator.user2().id().get());
-    assertThat(reviewers).containsExactly(user.id().get(), admin.id().get());
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void revertInitialCommit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot revert initial commit");
-    gApi.changes().id(r.getChangeId()).revert();
+    ResourceNotFoundException thrown =
+        assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(changeId).get());
+    assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
   @FunctionalInterface
@@ -827,19 +786,10 @@
     assertThat(cr.all.get(0).value).isEqualTo(1);
 
     // Rebasing the second change again should fail
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(changeId).current().rebase();
-  }
-
-  @Test
-  public void rebaseOnNonExistingChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    RebaseInput in = new RebaseInput();
-    in.base = "999999";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Base change not found: " + in.base);
-    gApi.changes().id(changeId).rebase(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @Test
@@ -902,6 +852,17 @@
   }
 
   @Test
+  public void rebaseOnNonExistingChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    RebaseInput in = new RebaseInput();
+    in.base = "999999";
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
+    assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
+  }
+
+  @Test
   public void rebaseFromRelationChainToClosedChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
     testRepo.reset("HEAD~1");
@@ -942,9 +903,9 @@
     // Rebase the second
     String changeId = r2.getChangeId();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -959,7 +920,11 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -979,15 +944,19 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -1002,13 +971,17 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -1024,14 +997,18 @@
     String changeId = changeResult.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
-    allow("refs/*", Permission.DELETE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     deleteChangeAsUser(admin, user);
   }
 
@@ -1040,32 +1017,40 @@
     GroupApi groupApi = gApi.groups().create(name("delete-change"));
     groupApi.addMembers("user");
 
+    Project.NameKey nameKey = Project.nameKey(name("delete-change"));
     ProjectInput in = new ProjectInput();
-    in.name = name("delete-change");
+    in.name = nameKey.get();
     in.owners = Lists.newArrayListWithCapacity(1);
     in.owners.add(groupApi.name());
     in.createEmptyCommit = true;
-    ProjectApi api = gApi.projects().create(in);
+    gApi.projects().create(in);
 
-    Project.NameKey nameKey = new Project.NameKey(api.get().name);
-
-    try (ProjectConfigUpdate u = updateProject(nameKey)) {
-      Util.allow(u.getConfig(), Permission.DELETE_CHANGES, PROJECT_OWNERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(nameKey)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(PROJECT_OWNERS))
+        .update();
 
     deleteChangeAsUser(nameKey, admin, user);
   }
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     deleteChangeAsUser(user, user);
   }
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, CHANGE_OWNER);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(CHANGE_OWNER))
+        .update();
     deleteChangeAsUser(user, user);
   }
 
@@ -1099,12 +1084,16 @@
 
       assertThat(query(changeId)).isEmpty();
 
-      String ref = new Change.Id(id).toRefPrefix() + "1";
+      String ref = Change.id(id).toRefPrefix() + "1";
       eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
       eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email());
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
-      removePermission(project, "refs/*", Permission.DELETE_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .remove(permissionKey(Permission.DELETE_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1115,18 +1104,26 @@
 
   @Test
   public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult = createChange();
       String changeId = changeResult.getChangeId();
 
       requestScopeOperations.setApiUser(user.id());
-      exception.expect(AuthException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      AuthException thrown =
+          assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1151,9 +1148,9 @@
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
@@ -1177,15 +1174,19 @@
 
     merge(changeResult);
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult =
@@ -1195,11 +1196,15 @@
       merge(changeResult);
 
       requestScopeOperations.setApiUser(user.id());
-      exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      MethodNotAllowedException thrown =
+          assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1212,10 +1217,11 @@
     merge(changeResult);
     setChangeStatus(id, Change.Status.NEW);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Cannot delete change %s: patch set 1 is already merged", id));
-    gApi.changes().id(changeId).delete();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot delete change %s: patch set 1 is already merged", id));
   }
 
   @Test
@@ -1229,10 +1235,10 @@
     Optional<ChangeData> result =
         idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
 
-    assertThat(result.isPresent()).isTrue();
+    assertThat(result).isPresent();
     gApi.changes().id(changeId).delete();
     result = idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
-    assertThat(result.isPresent()).isFalse();
+    assertThat(result).isEmpty();
   }
 
   @Test
@@ -1264,18 +1270,64 @@
   }
 
   @Test
+  public void deleteChangeRemovesItsChangeEdit() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
+
+    requestScopeOperations.setApiUser(admin.id());
+    try (Repository repo = repoManager.openRepository(project)) {
+      String expected =
+          RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
+      assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isNotEmpty();
+      gApi.changes().id(changeId).delete();
+      assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isEmpty();
+    }
+  }
+
+  @Test
+  public void deleteChangeDoesntRemoveOtherChangeEdits() throws Exception {
+    PushOneCommit.Result result = createChange();
+    PushOneCommit.Result irrelevantChangeResult = createChange();
+    requestScopeOperations.setApiUser(admin.id());
+    String changeId = result.getChangeId();
+    String irrelevantChangeId = irrelevantChangeResult.getChangeId();
+
+    gApi.changes().id(irrelevantChangeId).edit().create();
+    gApi.changes()
+        .id(irrelevantChangeId)
+        .edit()
+        .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(gApi.changes().id(irrelevantChangeId).edit().get()).isPresent();
+  }
+
+  @Test
   public void rebaseUpToDateChange() throws Exception {
     PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @Test
   public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     PushOneCommit push =
         pushFactory.create(
@@ -1285,11 +1337,11 @@
             PushOneCommit.FILE_NAME,
             "other content",
             "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+    assertThrows(
+        ResourceConflictException.class,
+        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
   }
 
   @Test
@@ -1303,23 +1355,23 @@
     ri.base = "";
     gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
     PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.getId().get()).isEqualTo(2);
+    assertThat(ps3.id().get()).isEqualTo(2);
 
     // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.getId().toRefName();
+    ri.base = ps3.id().toRefName();
     gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
     PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.getId().get()).isEqualTo(2);
+    assertThat(ps2.id().get()).isEqualTo(2);
 
     // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.getRevision().get();
+    ri.base = ps2.commitId().name();
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
     PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.getId().get()).isEqualTo(2);
+    assertThat(ps1.id().get()).isEqualTo(2);
 
     // rebase r1 onto r3 (referenced by change number)
     ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.getRevision().get()).rebase(ri);
+    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
     assertThat(r1.getPatchSetId().get()).isEqualTo(3);
   }
 
@@ -1334,9 +1386,11 @@
         "base change "
             + r2.getChangeId()
             + " is a descendant of the current change - recursion not allowed";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(expectedMessage);
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 
   @Test
@@ -1348,9 +1402,11 @@
     ChangeInfo info = info(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).revision(r.getCommit().name()).rebase();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -1370,9 +1426,11 @@
     RebaseInput ri = new RebaseInput();
     ri.base = r.getCommit().name();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("base change is abandoned: " + changeId);
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
   }
 
   @Test
@@ -1382,9 +1440,11 @@
     String commit = r.getCommit().name();
     RebaseInput ri = new RebaseInput();
     ri.base = commit;
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot rebase change onto itself");
-    gApi.changes().id(changeId).revision(commit).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
   }
 
   @Test
@@ -1466,11 +1526,12 @@
   public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit of user
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1486,12 +1547,8 @@
 
     // check the user cannot see the change
     requestScopeOperations.setApiUser(user.id());
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // check that the author/committer was NOT added as reviewer (he can't see
     // the change)
@@ -1539,11 +1596,12 @@
   public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit that references 'user' in a footer
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1563,12 +1621,8 @@
 
     // check that 'user' cannot see the change
     requestScopeOperations.setApiUser(user.id());
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // check that 'user' was NOT added as cc ('user' can't see the change)
     requestScopeOperations.setApiUser(admin.id());
@@ -1582,11 +1636,12 @@
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // create change
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1596,12 +1651,8 @@
 
     // check the user cannot see the change
     requestScopeOperations.setApiUser(user.id());
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // try to add user as reviewer
     requestScopeOperations.setApiUser(admin.id());
@@ -1684,16 +1735,36 @@
   }
 
   @Test
+  @UseClockStep
   public void addReviewer() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    testAddReviewerViaPostReview(
+        (changeId, reviewer) -> {
+          AddReviewerInput in = new AddReviewerInput();
+          in.reviewer = reviewer;
+          gApi.changes().id(changeId).addReviewer(in);
+        });
+  }
+
+  @Test
+  @UseClockStep
+  public void addReviewerViaPostReview() throws Exception {
+    testAddReviewerViaPostReview(
+        (changeId, reviewer) -> {
+          AddReviewerInput addReviewerInput = new AddReviewerInput();
+          addReviewerInput.reviewer = reviewer;
+          ReviewInput reviewInput = new ReviewInput();
+          reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+          gApi.changes().id(changeId).current().review(reviewInput);
+        });
+  }
+
+  private void testAddReviewerViaPostReview(AddReviewerCaller addReviewer) throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    addReviewer.call(r.getChangeId(), user.email());
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -1705,15 +1776,15 @@
     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);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
 
+    // Nobody was added as CC.
+    assertThat(c.reviewers.get(CC)).isNull();
+
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
@@ -1727,6 +1798,19 @@
   }
 
   @Test
+  public void postingMessageOnOwnChangeDoesntAddCallerAsReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "Foo Bar";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+  }
+
+  @Test
   public void listReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
@@ -1772,20 +1856,20 @@
     // In this case, the child ReviewerInput has a notify=OWNER_REVIEWERS
     // that should be ignored.
     r = createWorkInProgressChange();
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    gApi.changes().id(r.getChangeId()).current().review(batchIn);
     assertThat(sender.getMessages()).isEmpty();
 
     // Top-level notify property can force notifications when adding reviewer
     // via PostReview.
     r = createWorkInProgressChange();
     batchIn.notify = NotifyHandling.OWNER_REVIEWERS;
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    gApi.changes().id(r.getChangeId()).current().review(batchIn);
     assertThat(sender.getMessages()).hasSize(1);
   }
 
   @Test
+  @UseClockStep
   public void addReviewerThatIsNotPerfectMatch() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
@@ -1820,10 +1904,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);
@@ -1836,8 +1917,8 @@
   }
 
   @Test
+  @UseClockStep
   public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
@@ -1884,10 +1965,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);
@@ -1900,8 +1978,8 @@
   }
 
   @Test
+  @UseClockStep
   public void addSelfAsReviewer() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
@@ -1915,10 +1993,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();
@@ -1974,7 +2049,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();
@@ -2027,6 +2102,45 @@
   }
 
   @Test
+  public void pluginCanContributeToETagComputation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
+      assertThat(parseResource(r).getETag()).isNotEqualTo(oldETag);
+    }
+
+    assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+  }
+
+  @Test
+  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
+      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+    }
+  }
+
+  @Test
+  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                TestChangeETagComputation.withException(
+                    new StorageException("exception during test")))) {
+      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+    }
+  }
+
+  @Test
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -2067,8 +2181,8 @@
     comment.message = "comment 1";
     review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
 
-    exception.expect(BadRequestException.class);
-    gApi.changes().id(changeId).current().review(review);
+    assertThrows(
+        BadRequestException.class, () -> gApi.changes().id(changeId).current().review(review));
   }
 
   @Test
@@ -2093,21 +2207,21 @@
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
+    LabelType verified =
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -2135,8 +2249,9 @@
     // Remove again, and then try to remove once more to verify 404 is
     // returned.
     gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).reviewer(user.id().toString()).remove());
   }
 
   @Test
@@ -2197,9 +2312,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2215,9 +2332,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2249,9 +2368,11 @@
     gApi.changes().id(changeId).abandon();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2359,24 +2480,32 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete vote not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).deleteVote("Code-Review");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(admin.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-      AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, registered, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
+        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
     PushOneCommit.Result r = createChange();
@@ -2472,8 +2601,13 @@
 
   @Test
   public void queryChangesNoLimit() throws Exception {
-    allowGlobalCapabilities(
-        SystemGroupBackend.REGISTERED_USERS, 0, 2, GlobalCapability.QUERY_LIMIT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.REGISTERED_USERS)
+                .range(0, 2))
+        .update();
     for (int i = 0; i < 3; i++) {
       createChange();
     }
@@ -2611,16 +2745,21 @@
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit topic name not permitted");
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).topic("mytopic"));
+    assertThat(thrown).hasMessageThat().contains("edit topic name not permitted");
   }
 
   @Test
   public void editTopicWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_TOPIC_NAME).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).topic("mytopic");
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
@@ -2666,16 +2805,22 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit());
+    assertThat(thrown).hasMessageThat().contains("submit not permitted");
   }
 
   @Test
   public void submitAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
     assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
@@ -2691,22 +2836,24 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
-        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
-        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.getConfig().getLabelSections().put(custom1.getName(), custom1);
       u.getConfig().getLabelSections().put(custom2.getName(), custom2);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel("Verified"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom1"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom2"), -1, 1, anon, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom1.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom2.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r1 = createChange();
     r1.assertOkStatus();
@@ -2756,18 +2903,16 @@
   @Test
   public void customCommitFooters() throws Exception {
     PushOneCommit.Result change = createChange();
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            "gerrit",
-            (newCommitMessage, original, mergeTip, destination) -> {
-              assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-              return newCommitMessage + "Custom: " + destination.get();
-            });
     ChangeInfo actual;
-    try {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                (newCommitMessage, original, mergeTip, destination) -> {
+                  assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+                  return newCommitMessage + "Custom: " + destination.branch();
+                })) {
       actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
-    } finally {
-      handle.remove();
     }
     List<String> footers =
         new ArrayList<>(
@@ -2829,10 +2974,11 @@
     assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.value).isEqualTo(0);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
@@ -2878,13 +3024,13 @@
 
     info = gApi.changes().id(info._number).get();
     assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    exception.expect(AuthException.class);
-    gApi.changes().id(triplet).current().review(ReviewInput.approve());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(triplet).current().review(ReviewInput.approve()));
   }
 
   @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())
@@ -2894,7 +3040,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commitPatchSetCreation =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
@@ -2937,8 +3083,7 @@
     in.project = project.get();
     in.newBranch = true;
 
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().create(in).get();
+    assertThrows(ResourceConflictException.class, () -> gApi.changes().create(in).get());
   }
 
   @Test
@@ -2951,7 +3096,11 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
@@ -2959,12 +3108,12 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
     PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().get() + ".");
   }
 
   @Test
@@ -2979,7 +3128,7 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
@@ -2995,7 +3144,11 @@
     TestRepository<?> adminTestRepo = cloneProject(project, admin);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
@@ -3003,7 +3156,7 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(adminTestRepo, r1.getPatchSet().refName() + ":ps");
     adminTestRepo.reset("ps");
 
     // Amend change as admin
@@ -3014,20 +3167,19 @@
 
   @Test
   public void createMergePatchSet() throws Exception {
-    PushOneCommit.Result start = pushTo("refs/heads/master");
-    start.assertOkStatus();
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    String changeId = r.getChangeId();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
 
-    testRepo.reset(start.getCommit());
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
     PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
     currentMaster.assertOkStatus();
     String parent = currentMaster.getCommit().getName();
 
     // push a commit into dev branch
-    createBranch("dev");
+    testRepo.reset(initialHead);
     PushOneCommit.Result changeA =
         pushFactory
             .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
@@ -3048,22 +3200,57 @@
   }
 
   @Test
+  public void createMergePatchSet_Conflict() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+  }
+
+  @Test
   public void createMergePatchSetInheritParent() throws Exception {
-    PushOneCommit.Result start = pushTo("refs/heads/master");
-    start.assertOkStatus();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
     // create a change for master
     PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
     String changeId = r.getChangeId();
     String parent = r.getCommit().getParent(0).getName();
 
     // advance master branch
-    testRepo.reset(start.getCommit());
+    testRepo.reset(initialHead);
     PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
     currentMaster.assertOkStatus();
 
     // push a commit into dev branch
-    createBranch("dev");
+    testRepo.reset(initialHead);
     PushOneCommit.Result changeA =
         pushFactory
             .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
@@ -3089,7 +3276,7 @@
 
   @Test
   public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -3106,14 +3293,19 @@
     testRepo.reset(initialHead);
     String changeId = createChange().getChangeId();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Read not permitted for " + baseChange);
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
   }
 
   @Test
   public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -3140,6 +3332,46 @@
         .isEqualTo(expectedParent);
   }
 
+  @Test
+  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.strategy = "unsupported-strategy";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+  }
+
   private MergePatchSetInput createMergePatchSetInput(String baseChange) {
     MergeInput mergeInput = new MergeInput();
     mergeInput.source = "foo";
@@ -3161,15 +3393,18 @@
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     String heads = RefNames.REFS_HEADS + "*";
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -3187,9 +3422,16 @@
       // remove label and assert that it's no longer returned for existing
       // changes, even if there is an approval for it
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            permissionKey(Permission.forLabel(verified.getName()))
+                .ref(heads)
+                .group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3216,17 +3458,20 @@
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 2);
 
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // add new label and assert that it's returned for existing changes
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -3268,9 +3513,13 @@
     // changes, even if there is an approval for it
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(verified.getName()).ref(heads).group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3279,9 +3528,45 @@
   }
 
   @Test
+  public void notifyConfigForDirectoryTriggersEmail() throws Exception {
+    // Configure notifications on project level.
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Configure Notifications",
+            "project.config",
+            "[notify \"my=notify-config\"]\n"
+                + "  email = foo@test.com\n"
+                + "  filter = dir:\\\"foo/bar/baz\\\"");
+    push.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Push a change that matches the filter.
+    sender.clear();
+    push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Test change", "foo/bar/baz/test.txt", "some content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+
+    // Comment on the change.
+    sender.clear();
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "some message";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+  }
+
+  @Test
   public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
     // Configure Non-Author-Code-Review
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
@@ -3306,20 +3591,18 @@
     push2.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
 
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // Allow user to approve
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     PushOneCommit.Result r = createChange();
 
@@ -3371,16 +3654,15 @@
     assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
     assertThat(approval.permittedVotingRange.max).isEqualTo(1);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel("Code-Review"),
-          minPermittedValue,
-          maxPermittedValue,
-          REGISTERED_USERS,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(minPermittedValue, maxPermittedValue))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
@@ -3394,10 +3676,11 @@
 
   @Test
   public void maxPermittedValueBlocked() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3434,9 +3717,9 @@
     ReviewInput input = new ReviewInput().label("Code-Review", 3);
     gApi.changes().id(changeId).current().review(input);
 
-    Map<String, Short> votes =
-        gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
-    assertThat(votes).isEmpty();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().reviewer(admin.email()));
   }
 
   @Test
@@ -3445,9 +3728,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Style", 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Style\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Style\" is not a configured label");
   }
 
   @Test
@@ -3456,9 +3740,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Review", 3);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Review\": 3 is not a valid value");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Review\": 3 is not a valid value");
   }
 
   @Test
@@ -3474,7 +3759,7 @@
             + "U > 0,"
             + "R = label('All-Comments-Resolved', need(_)). \n\n");
 
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
@@ -3486,56 +3771,14 @@
 
     gApi.changes().id(result1.getChangeId()).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs All-Comments-Resolved");
-    gApi.changes().id(result2.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
-    addPureRevertSubmitRule();
-
-    // Create a change that is not a revert of another change
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    approve(r1.getChangeId());
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(r1.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and push a content change
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    amendChange(revertId);
-    approve(revertId);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(revertId).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
-    // Create a change that we can later revert
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and submit it
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    approve(revertId);
-    gApi.changes().id(revertId).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result2.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
   }
 
   @Test
@@ -3595,13 +3838,13 @@
   }
 
   @Test
-  public void changeCommitMessageWithNoChangeIdFails() throws Exception {
+  public void changeCommitMessageWithNoChangeIdRetainsChangeID() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("missing Change-Id footer");
     gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("modified commit\n\nChange-Id: " + r.getChangeId() + "\n");
   }
 
   @Test
@@ -3609,11 +3852,14 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("NUL character");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("test\0commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage("test\0commit\n\nChange-Id: " + r.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("NUL character");
   }
 
   @Test
@@ -3622,11 +3868,15 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("wrong Change-Id footer");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage(
+                        "modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("wrong Change-Id footer");
   }
 
   @Test
@@ -3635,15 +3885,20 @@
     Project.NameKey p = projectOperations.newProject().create();
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
     // Create change as user
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     // Try to change the commit message
-    exception.expect(AuthException.class);
-    exception.expectMessage("modifying commit message not permitted");
-    gApi.changes().id(r.getChangeId()).setMessage("foo");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).setMessage("foo"));
+    assertThat(thrown).hasMessageThat().contains("modifying commit message not permitted");
   }
 
   @Test
@@ -3651,9 +3906,11 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("new and existing commit message are the same");
-    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId())));
+    assertThat(thrown).hasMessageThat().contains("new and existing commit message are the same");
   }
 
   @Test
@@ -3693,114 +3950,13 @@
   }
 
   @Test
-  public void pureRevertReturnsTrueForPureRevert() throws Exception {
-    PushOneCommit.Result r = createChange();
-    merge(r);
-    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
-    // Without query parameter
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-    // With query parameter
-    assertThat(
-            gApi.changes()
-                .id(revertId)
-                .pureRevert(getRemoteHead().toObjectId().name())
-                .isPureRevert)
-        .isTrue();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseOnContentChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    merge(r1);
-    // Create a revert and expect pureRevert to be true
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-
-    // Create a new PS and expect pureRevert to be false
-    PushOneCommit.Result result = amendChange(revertId);
-    result.assertOkStatus();
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertParameterTakesPrecedence() throws Exception {
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-    String oldHead = getRemoteHead().toObjectId().name();
-
-    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
-    merge(r2);
-
-    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    merge(r1);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid object ID");
-    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
-  }
-
-  @Test
-  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-
-    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
-    merge(r2);
-
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    // Rebase revert onto HEAD
-    gApi.changes().id(revertId).rebase();
-    // Check that pureRevert is true which implies that the commit can be rebased onto the original
-    // commit.
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
-    // Create an initial commit to serve as claimed original
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-    String claimedOriginal = getRemoteHead().toObjectId().name();
-
-    // Change contents of the file to provoke a conflict
-    merge(createChange("commit message", "a.txt", "content2"));
-
-    // Create a commit that we can revert
-    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
-    merge(r2);
-
-    // Create a revert of r2
-    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
-    // Assert that the change is a pure revert of it's 'revertOf'
-    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
-    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
-    // to rebase this on claimed original, which fails.
-    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
-    assertThat(pureRevert.isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("revertOf not set");
-    gApi.changes().id(createChange().getChangeId()).pureRevert();
-  }
-
-  @Test
   public void putTopicExceedLimitFails() throws Exception {
     String changeId = createChange().getChangeId();
     String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("topic length exceeds the limit");
-    gApi.changes().id(changeId).topic(topic);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).topic(topic));
+    assertThat(thrown).hasMessageThat().contains("topic length exceeds the limit");
   }
 
   @Test
@@ -3817,13 +3973,13 @@
 
   private void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = "Code-Review";
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    AccountGroup.UUID registered = REGISTERED_USERS;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label).ref("refs/heads/*").group(registered).range(-1, +1))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-2, +2))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     PushOneCommit.Result r = createChange();
@@ -3846,15 +4002,13 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     requestScopeOperations.setApiUser(admin.id());
-    // Remove user's permission for 'Label'.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(label), registered, "refs/heads/*");
-      // Update user's permitted range for 'Code-Review' to be -1...+1.
-      Util.remove(u.getConfig(), Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(label).ref("refs/heads/*").group(registered))
+        .remove(labelPermissionKey(codeReviewLabel).ref("refs/heads/*").group(registered))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-1, +1))
+        .update();
 
     // Verify user's new permitted range.
     requestScopeOperations.setApiUser(user.id());
@@ -3938,7 +4092,7 @@
     if (r == null) {
       return ImmutableList.of();
     }
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 
   private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
@@ -3953,7 +4107,7 @@
             .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
             .map(Map.Entry::getKey)
             .collect(toSet());
-    assertThat(states.size()).named(states.toString()).isAtMost(1);
+    assertWithMessage(states.toString()).that(states.size()).isAtMost(1);
     return states.stream().findFirst();
   }
 
@@ -3979,10 +4133,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);
 
@@ -3990,19 +4141,6 @@
     }
   }
 
-  private void addPureRevertSubmitRule() throws Exception {
-    modifySubmitRules(
-        "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(1), \n"
-            + "!,"
-            + "gerrit:uploader(U), \n"
-            + "R = label('Is-Pure-Revert', ok(U)).\n"
-            + "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(U), \n"
-            + "U \\= 1,"
-            + "R = label('Is-Pure-Revert', need(_)). \n\n");
-  }
-
   private void modifySubmitRules(String newContent) throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
@@ -4042,21 +4180,25 @@
 
   @Test
   public void starUnstar() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    changeIndexedCounter.clear();
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      PushOneCommit.Result r = createChange();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      changeIndexedCounter.clear();
 
-    gApi.accounts().self().starChange(triplet);
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).contains(DEFAULT_LABEL);
-    changeIndexedCounter.assertReindexOf(change);
+      gApi.accounts().self().starChange(triplet);
+      ChangeInfo change = info(triplet);
+      assertThat(change.starred).isTrue();
+      assertThat(change.stars).contains(DEFAULT_LABEL);
+      changeIndexedCounter.assertReindexOf(change);
 
-    gApi.accounts().self().unstarChange(triplet);
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).isNull();
-    changeIndexedCounter.assertReindexOf(change);
+      gApi.accounts().self().unstarChange(triplet);
+      change = info(triplet);
+      assertThat(change.starred).isNull();
+      assertThat(change.stars).isNull();
+      changeIndexedCounter.assertReindexOf(change);
+    }
   }
 
   @Test
@@ -4116,9 +4258,9 @@
   public void cannotIgnoreOwnChange() throws Exception {
     String changeId = createChange().getChangeId();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot ignore own change");
-    gApi.changes().id(changeId).ignore(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
   }
 
   @Test
@@ -4129,14 +4271,17 @@
     gApi.accounts().self().starChange(changeId);
     assertThat(gApi.changes().id(changeId).get().starred).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.changes().id(changeId).ignore(true);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4147,14 +4292,17 @@
     gApi.changes().id(changeId).ignore(true);
     assertThat(gApi.changes().id(changeId).ignored()).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts().self().starChange(changeId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4192,21 +4340,28 @@
     gApi.changes().id(changeId).markAsReviewed(true);
     assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(
+                            ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4217,21 +4372,27 @@
     gApi.changes().id(changeId).markAsReviewed(false);
     assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4260,9 +4421,14 @@
 
     // label cannot contain whitespace
     String invalidLabel = "invalid label";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: " + invalidLabel);
-    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel))));
+    assertThat(thrown).hasMessageThat().contains("invalid labels: " + invalidLabel);
   }
 
   @Test
@@ -4279,7 +4445,8 @@
             ListChangesOption.MESSAGES,
             ListChangesOption.SUBMITTABLE,
             ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_MERGEABLE);
+            ListChangesOption.SKIP_MERGEABLE,
+            ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
     int number = gApi.changes().id(change.getChangeId()).get()._number;
@@ -4295,7 +4462,7 @@
   }
 
   private BranchApi createBranch(String branch) throws Exception {
-    return createBranch(new Branch.NameKey(project, branch));
+    return createBranch(BranchNameKey.create(project, branch));
   }
 
   private ThrowableSubject assertThatQueryException(String query) throws Exception {
@@ -4306,4 +4473,9 @@
     }
     throw new AssertionError("expected BadRequestException");
   }
+
+  @FunctionalInterface
+  private interface AddReviewerCaller {
+    void call(String changeId, String reviewer) throws RestApiException;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index 4551f90..de73c00 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
@@ -54,16 +55,22 @@
 
   @Test
   public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo._number);
-    gApi.changes().id("unknown", changeInfo._number);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo._number));
+    assertThat(thrown).hasMessageThat().contains("Not found: unknown~" + changeInfo._number);
   }
 
   @Test
   public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
-    gApi.changes().id(project.get(), Integer.MAX_VALUE);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), Integer.MAX_VALUE));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
   }
 
   @Test
@@ -74,8 +81,7 @@
 
   @Test
   public void wrongChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(Integer.MAX_VALUE);
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(Integer.MAX_VALUE));
   }
 
   @Test
@@ -86,25 +92,36 @@
 
   @Test
   public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
-    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
-    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), "unknown", changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
     String unknownId = "I1234567890";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(
-        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
-    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), changeInfo.branch, unknownId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
   }
 
   @Test
@@ -119,7 +136,6 @@
 
   @Test
   public void wrongChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id("I1234567890");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id("I1234567890"));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 48c46d2..a704f0c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -26,15 +26,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Test;
 
@@ -109,15 +108,15 @@
     }
 
     @Override
-    public Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options) {
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
       if (block.get()) {
         SubmitRecord record = new SubmitRecord();
         record.labels = new ArrayList<>();
         record.status = SubmitRecord.Status.NOT_READY;
         record.requirements = ImmutableList.of(req);
-        return ImmutableList.of(record);
+        return Optional.of(record);
       }
-      return ImmutableList.of();
+      return Optional.empty();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
index ae88afd..42d62bd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -15,25 +15,24 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class DisablePrivateChangesIT extends AbstractDaemonTest {
-
   @Test
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void createPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
     ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     input.isPrivate = true;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
@@ -59,20 +58,6 @@
   }
 
   @Test
-  @GerritConfig(name = "change.allowDrafts", value = "true")
-  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
-  public void pushDraftsWithDisablePrivateChangesTrue() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result result =
-        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
-    result.assertErrorStatus();
-
-    testRepo.reset(initialHead);
-    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
-    result.assertErrorStatus();
-  }
-
-  @Test
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void pushWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result =
@@ -82,34 +67,15 @@
   }
 
   @Test
-  @GerritConfig(name = "change.allowDrafts", value = "true")
-  public void pushPrivatesWithDisablePrivateChangesFalse() throws Exception {
-    PushOneCommit.Result result =
-        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%private");
-    assertThat(result.getChange().change().isPrivate()).isTrue();
-  }
-
-  @Test
-  @GerritConfig(name = "change.allowDrafts", value = "true")
-  public void pushDraftsWithDisablePrivateChangesFalse() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result result =
-        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
-    assertThat(result.getChange().change().isPrivate()).isTrue();
-
-    testRepo.reset(initialHead);
-    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
-    assertThat(result.getChange().change().isPrivate()).isTrue();
-  }
-
-  @Test
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void setPrivateWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, "set private"));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
index a08d417..7354ca4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -152,18 +154,26 @@
   public void editMergeList() throws Exception {
     gApi.changes().id(changeId).edit().create();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Invalid path: " + MERGE_LIST);
-    gApi.changes().id(changeId).edit().modifyFile(MERGE_LIST, RawInputUtil.create("new content"));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(MERGE_LIST, RawInputUtil.create("new content")));
+    assertThat(thrown).hasMessageThat().contains("Invalid path: " + MERGE_LIST);
   }
 
   @Test
   public void deleteMergeList() throws Exception {
     gApi.changes().id(changeId).edit().create();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   private String getMergeListContent(RevCommit... commits) {
@@ -171,7 +181,7 @@
     for (RevCommit c : commits) {
       mergeList
           .append("* ")
-          .append(c.abbreviate(8).name())
+          .append(abbreviateName(c, 8))
           .append(" ")
           .append(c.getShortMessage())
           .append("\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
new file mode 100644
index 0000000..7156c8d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -0,0 +1,290 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.sql.Timestamp;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+
+/** Tests for comment validation in {@link PostReview}. */
+public class PostReviewIT extends AbstractDaemonTest {
+  @Inject private CommentValidator mockCommentValidator;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> capture;
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        CommentValidator mockCommentValidator = mock(CommentValidator.class);
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @Before
+  public void resetMock() {
+    initMocks(this);
+    clearInvocations(mockCommentValidator);
+  }
+
+  @Test
+  public void validateCommentsInInput_commentOK() throws Exception {
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of());
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
+    comment.updated = new Timestamp(0);
+    input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateCommentsInInput_commentRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
+    comment.updated = new Timestamp(0);
+    input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(COMMENT_TEXT);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateCommentsInInput_commentCleanedUp() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    // posting a comment which is empty after trim is a no-op, as the empty comment is dropped
+    // during comment cleanup
+    ReviewInput input = new ReviewInput();
+    CommentInput comment =
+        TestCommentHelper.populate(
+            new CommentInput(), r.getChange().currentFilePaths().get(0), " ");
+    comment.updated = new Timestamp(0);
+    input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateDrafts_draftOK() throws Exception {
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of());
+
+    PushOneCommit.Result r = createChange();
+
+    DraftInput draft =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft).get();
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateDrafts_draftRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.INLINE_COMMENT, COMMENT_TEXT);
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    PushOneCommit.Result r = createChange();
+
+    DraftInput draft =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(draft.message);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateDrafts_inlineVsFileComments_allOK() throws Exception {
+    PushOneCommit.Result r = createChange();
+    DraftInput draftInline =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftInline);
+    DraftInput draftFile = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftFile);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    when(mockCommentValidator.validateComments(capture.capture())).thenReturn(ImmutableList.of());
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(2);
+
+    assertThat(capture.getAllValues()).hasSize(1);
+    assertThat(capture.getValue())
+        .containsExactly(
+            CommentForValidation.create(
+                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+            CommentForValidation.create(
+                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+  }
+
+  @Test
+  public void validateCommentsInChangeMessage_messageOK() throws Exception {
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of());
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
+    int numMessages = gApi.changes().id(r.getChangeId()).get().messages.size();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages).hasSize(numMessages + 1);
+    ChangeMessageInfo message =
+        Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(message.message).contains(COMMENT_TEXT);
+  }
+
+  @Test
+  public void validateCommentsInChangeMessage_messageRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages)
+        .hasSize(1); // From the initial commit.
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(COMMENT_TEXT);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages)
+        .hasSize(1); // Unchanged from before.
+    ChangeMessageInfo message =
+        Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(message.message).doesNotContain(COMMENT_TEXT);
+  }
+
+  private static CommentInput newComment(String path) {
+    return TestCommentHelper.populate(new CommentInput(), path, PostReviewIT.COMMENT_TEXT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index bfcb1a8..f043c9b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -41,6 +44,7 @@
 
 public class PrivateChangeIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -89,9 +93,9 @@
     String changeId = result.getChangeId();
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot set merged change to private");
-    gApi.changes().id(changeId).setPrivate(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set merged change to private");
   }
 
   @Test
@@ -102,9 +106,9 @@
     gApi.changes().id(changeId).abandon();
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot set abandoned change to private");
-    gApi.changes().id(changeId).setPrivate(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set abandoned change to private");
   }
 
   @Test
@@ -126,9 +130,11 @@
   public void cannotSetOtherUsersChangePrivate() throws Exception {
     PushOneCommit.Result result = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
   }
 
   @Test
@@ -153,9 +159,10 @@
     gApi.changes().id(result.getChangeId()).reviewer(admin.id().toString()).remove();
 
     // This change should not be visible for admin anymore.
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + result.getChangeId());
-    gApi.changes().id(result.getChangeId());
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()));
+    assertThat(thrown).hasMessageThat().contains("Not found: " + result.getChangeId());
   }
 
   @Test
@@ -163,7 +170,11 @@
     PushOneCommit.Result result = createChange();
     gApi.changes().id(result.getChangeId()).setPrivate(true, null);
 
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
   }
@@ -173,7 +184,7 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     merge(result);
-    markMergedChangePrivate(new Change.Id(gApi.changes().id(changeId).get()._number));
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
 
     gApi.changes().id(changeId).setPrivate(false, null);
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
@@ -191,9 +202,9 @@
     merge(result);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(changeId).setPrivate(true, null);
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
   }
 
   @Test
@@ -218,7 +229,7 @@
     String changeId = result.getChangeId();
     gApi.changes().id(changeId).addReviewer(admin.id().toString());
     merge(result);
-    markMergedChangePrivate(new Change.Id(gApi.changes().id(changeId).get()._number));
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).setPrivate(false, null);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 843527a..78354d6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -48,7 +48,7 @@
     queryChanges.addQuery("is:wip repo:" + project.get());
 
     List<List<ChangeInfo>> result =
-        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE);
+        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
     assertThat(result).hasSize(2);
     assertThat(result.get(0)).hasSize(2);
     assertThat(result.get(1)).hasSize(1);
@@ -75,7 +75,7 @@
     queryChanges.addQuery(queryWithMoreChanges);
     queryChanges.addQuery(queryWithNoMoreChanges);
     List<List<ChangeInfo>> result =
-        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE);
+        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
     assertThat(result).hasSize(2);
     assertThat(result.get(0)).hasSize(1);
     assertThat(result.get(1)).hasSize(3);
@@ -88,7 +88,7 @@
     queryChanges2.addQuery(queryWithNoMoreChanges);
     queryChanges2.addQuery(queryWithMoreChanges);
     List<List<ChangeInfo>> result2 =
-        (List<List<ChangeInfo>>) queryChanges2.apply(TopLevelResource.INSTANCE);
+        (List<List<ChangeInfo>>) queryChanges2.apply(TopLevelResource.INSTANCE).value();
     assertThat(result2).hasSize(2);
     assertThat(result2.get(0)).hasSize(3);
     assertThat(result2.get(1)).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
new file mode 100644
index 0000000..0607a3c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -0,0 +1,454 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class RevertIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
+    addPureRevertSubmitRule();
+
+    // Create a change that is not a revert of another change
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
+    approve(r1.getChangeId());
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+  }
+
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and push a content change
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    amendChange(revertId);
+    approve(revertId);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(revertId).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+  }
+
+  @Test
+  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
+    // Create a change that we can later revert
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and submit it
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    approve(revertId);
+    gApi.changes().id(revertId).current().submit();
+  }
+
+  @Test
+  public void pureRevertReturnsTrueForPureRevert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
+    // Without query parameter
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    // With query parameter
+    assertThat(
+            gApi.changes()
+                .id(revertId)
+                .pureRevert(
+                    projectOperations.project(project).getHead("master").toObjectId().name())
+                .isPureRevert)
+        .isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnContentChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+    // Create a revert and expect pureRevert to be true
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+
+    // Create a new PS and expect pureRevert to be false
+    PushOneCommit.Result result = amendChange(revertId);
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertParameterTakesPrecedence() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String oldHead = projectOperations.project(project).getHead("master").toObjectId().name();
+
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id"));
+    assertThat(thrown).hasMessageThat().contains("invalid object ID");
+  }
+
+  @Test
+  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+
+    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    // Rebase revert onto HEAD
+    gApi.changes().id(revertId).rebase();
+    // Check that pureRevert is true which implies that the commit can be rebased onto the original
+    // commit.
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
+    // Create an initial commit to serve as claimed original
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String claimedOriginal =
+        projectOperations.project(project).getHead("master").toObjectId().name();
+
+    // Change contents of the file to provoke a conflict
+    merge(createChange("commit message", "a.txt", "content2"));
+
+    // Create a commit that we can revert
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
+    merge(r2);
+
+    // Create a revert of r2
+    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
+    // Assert that the change is a pure revert of it's 'revertOf'
+    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
+    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
+    // to rebase this on claimed original, which fails.
+    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
+    assertThat(pureRevert.isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert());
+    assertThat(thrown).hasMessageThat().contains("revertOf not set");
+  }
+
+  @Test
+  public void revert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
+
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // 4. Patch Set 1: Reverted
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(4);
+    String expectedMessage =
+        String.format("Created a revert of this change as %s", revertChange.changeId);
+    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+
+    assertThat(revertChange.messages).hasSize(1);
+    assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number);
+  }
+
+  @Test
+  public void revertWithDefaultTopic() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).topic("topic");
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).topic())
+        .isEqualTo("topic");
+  }
+
+  @Test
+  public void revertWithSetTopic() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).topic("topic");
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    revertInput.topic = "reverted-not-default";
+    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).topic())
+        .isEqualTo(revertInput.topic);
+  }
+
+  @Test
+  public void revertWithSetMessage() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    revertInput.message = "Message from input";
+    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).get().subject)
+        .isEqualTo(revertInput.message);
+  }
+
+  @Test
+  public void revertNotifications() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    sender.clear();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+    assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
+    assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
+  }
+
+  @Test
+  public void suppressRevertNotifications() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertPreservesReviewersAndCcs() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email());
+    in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
+    // Add user as reviewer that will create the revert
+    in.reviewer(accountCreator.admin2().email());
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    // expect both the original reviewers and CCs to be preserved
+    // original owner should be added as reviewer, user requesting the revert (new owner) removed
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(result).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs).containsExactly(accountCreator.user2().id().get());
+    assertThat(reviewers).containsExactly(user.id().get(), admin.id().get());
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void revertInitialCommit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("Cannot revert initial commit");
+  }
+
+  @Test
+  public void cantRevertNonMergedCommit() throws Exception {
+    PushOneCommit.Result result = createChange();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result.getChangeId()).revert());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("change is " + ChangeUtil.status(result.getChange().change()));
+  }
+
+  @Test
+  public void cantCreateRevertWithoutProjectWritePermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("project state " + ProjectState.READ_ONLY + " does not permit write");
+  }
+
+  @Test
+  public void cantCreateRevertWithoutCreateChangePermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
+
+    PermissionDeniedException thrown =
+        assertThrows(
+            PermissionDeniedException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("not permitted: create change on refs/heads/master");
+  }
+
+  @Test
+  public void cantCreateRevertWithoutReadPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + r.getChangeId());
+  }
+
+  @Override
+  protected PushOneCommit.Result createChange() throws Exception {
+    return createChange("refs/for/master");
+  }
+
+  @Override
+  protected PushOneCommit.Result createChange(String ref) throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  private void addPureRevertSubmitRule() throws Exception {
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(1), \n"
+            + "!,"
+            + "gerrit:uploader(U), \n"
+            + "R = label('Is-Pure-Revert', ok(U)).\n"
+            + "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(U), \n"
+            + "U \\= 1,"
+            + "R = label('Is-Pure-Revert', need(_)). \n\n");
+  }
+
+  private void modifySubmitRules(String newContent) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.newIdent())
+          .committer(admin.newIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index d53b305..7e69251 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
@@ -24,19 +26,21 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
+import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -44,12 +48,14 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
+import com.google.inject.name.Named;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,15 +66,20 @@
 
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
+  @Inject
+  @Named("change_kind")
+  private Cache<ChangeKindCacheImpl.Key, ChangeKind> changeKindCache;
+
   @Before
   public void setup() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       // Overwrite "Code-Review" label that is inherited from All-Projects.
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType codeReview =
-          category(
+          label(
               "Code-Review",
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
@@ -79,28 +90,26 @@
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
 
       LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().getLabelSections().put(verified.getName(), verified);
 
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   @Test
@@ -110,6 +119,28 @@
   }
 
   @Test
+  public void stickyOnAnyScore() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyAnyScore(true);
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 1, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
@@ -118,7 +149,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, -1, 1);
@@ -140,7 +171,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -177,7 +208,7 @@
     assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
 
     // check that votes are sticky when trivial rebase is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -188,7 +219,7 @@
     assertVotes(c, user, -2, 0);
 
     // check that votes are not sticky when rework is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -277,7 +308,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -320,6 +351,42 @@
   }
 
   @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
+    // The purpose of this test is to make sure that we compute change kind only against the parent
+    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
+    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
+    // work in O(num-patch-sets). This test ensures that we aren't regressing.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.save();
+    }
+
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, 2, 1);
+    updateChange(changeId, NO_CODE_CHANGE);
+    updateChange(changeId, NO_CODE_CHANGE);
+    updateChange(changeId, NO_CODE_CHANGE);
+
+    Map<Integer, ObjectId> revisions = new HashMap<>();
+    gApi.changes()
+        .id(changeId)
+        .get()
+        .revisions
+        .forEach(
+            (revId, revisionInfo) ->
+                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
+    assertThat(revisions.size()).isEqualTo(4);
+    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
+    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
+    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
+
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
+  }
+
+  @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
@@ -379,13 +446,25 @@
     assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
   }
 
+  private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
+    ChangeKind kind =
+        changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
+    assertThat(kind).isNotNull();
+  }
+
+  private void assertChangeKindCacheDoesNotContain(ObjectId prior, ObjectId next) {
+    ChangeKind kind =
+        changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
+    assertThat(kind).isNull();
+  }
+
   private ChangeInfo detailedChange(String changeId) throws Exception {
     return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
   }
 
   private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
     for (ChangeKind changeKind : changeKinds) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, +2, 1);
@@ -430,7 +509,7 @@
         noChange(changeId);
         return;
       default:
-        fail("unexpected change kind: " + changeKind);
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
     }
   }
 
@@ -476,7 +555,7 @@
 
   private void trivialRebase(String changeId) throws Exception {
     requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -557,10 +636,10 @@
       case NO_CHANGE:
       case MERGE_FIRST_PARENT_UPDATE:
       default:
-        fail("unexpected change kind: " + changeKind);
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
     }
 
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit.Result r =
         pushFactory
             .create(
@@ -581,7 +660,7 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
     in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).revision("current").cherryPick(in).get();
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
     return c.changeId;
   }
 
@@ -634,6 +713,6 @@
     if (changeKind != null) {
       name += "; changeKind = " + changeKind.name();
     }
-    assertThat(vote).named(name).isEqualTo(expectedVote);
+    assertWithMessage(name).that(vote).isEqualTo(expectedVote);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index 6c0e9ab..dab2d00 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -21,11 +21,14 @@
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -33,8 +36,6 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.testing.ConfigSuite;
@@ -237,22 +238,21 @@
     gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
 
-    try {
-      gApi.changes().id(r2.getChangeId()).current().submit();
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 2 changes due to the following problems:\n"
-                  + "Change "
-                  + r1.getChange().getId()
-                  + ": Change has submit type "
-                  + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
-                  + "from change "
-                  + r2.getChange().getId()
-                  + " in the same batch");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change "
+                + r1.getChange().getId()
+                + ": Change has submit type "
+                + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
+                + "from change "
+                + r2.getChange().getId()
+                + " in the same batch");
   }
 
   @Test
@@ -262,8 +262,8 @@
     TestSubmitRuleInput in = new TestSubmitRuleInput();
     in.rule = "invalid prolog rule";
     // We have no rules.pl by default. The fact that the default rules are showing up here is a bug.
-    List<TestSubmitRuleInfo> response = gApi.changes().id(changeId).current().testSubmitRule(in);
-    assertThat(response).containsExactly(invalidPrologRuleInfo());
+    TestSubmitRuleInfo response = gApi.changes().id(changeId).current().testSubmitRule(in);
+    assertThat(response).isEqualTo(invalidPrologRuleInfo());
   }
 
   @Test
@@ -274,8 +274,8 @@
 
     TestSubmitRuleInput in = new TestSubmitRuleInput();
     in.rule = "invalid prolog rule";
-    List<TestSubmitRuleInfo> response = gApi.changes().id(changeId).current().testSubmitRule(in);
-    assertThat(response).containsExactly(invalidPrologRuleInfo());
+    TestSubmitRuleInfo response = gApi.changes().id(changeId).current().testSubmitRule(in);
+    assertThat(response).isEqualTo(invalidPrologRuleInfo());
   }
 
   private static TestSubmitRuleInfo invalidPrologRuleInfo() {
diff --git a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
index fd08838..70aa557 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
     DiffPreferencesInfo result = gApi.config().server().setDefaultDiffPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultDiffPreferences();
     DiffPreferencesInfo expected = DiffPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
index e89aa3d..02f1ec3 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     EditPreferencesInfo update = new EditPreferencesInfo();
     update.lineLength = newLineLength;
     EditPreferencesInfo result = gApi.config().server().setDefaultEditPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultEditPreferences();
     EditPreferencesInfo expected = EditPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
index c606982..221e171 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -36,7 +36,7 @@
     GeneralPreferencesInfo update = new GeneralPreferencesInfo();
     update.signedOffBy = newSignedOffBy;
     GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
-    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
+    assertWithMessage("signedOffBy").that(result.signedOffBy).isEqualTo(newSignedOffBy);
 
     result = gApi.config().server().getDefaultPreferences();
     GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index d7d311b..41ae370 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -20,11 +20,11 @@
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
@@ -57,7 +57,7 @@
   @Test
   public void indexingUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -74,7 +74,7 @@
   public void indexCannotBeCorruptedByStaleCache() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
     loadGroupToCache(groupUuid);
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -102,7 +102,7 @@
   @Test
   public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -139,7 +139,7 @@
 
   private AccountGroup.UUID createGroup(String name) throws RestApiException {
     GroupInfo group = gApi.groups().create(name).get();
-    return new AccountGroup.UUID(group.id);
+    return AccountGroup.uuid(group.id);
   }
 
   private void reloadGroupToCache(AccountGroup.UUID groupUuid) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index e269e68..e6c3919 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
@@ -51,6 +54,7 @@
 public class GroupsConsistencyIT extends AbstractDaemonTest {
 
   @Inject protected GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   private GroupInfo gAdmin;
   private GroupInfo g1;
   private GroupInfo g2;
@@ -59,7 +63,10 @@
 
   @Before
   public void basicSetup() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     String name1 = groupOperations.newGroup().name("g1").create().get();
     String name2 = groupOperations.newGroup().name("g2").create().get();
@@ -94,7 +101,7 @@
   public void missingGroupRef() throws Exception {
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      RefUpdate ru = repo.updateRef(RefNames.refsGroups(new AccountGroup.UUID(g1.id)));
+      RefUpdate ru = repo.updateRef(RefNames.refsGroups(AccountGroup.uuid(g1.id)));
       ru.setForceUpdate(true);
       RefUpdate.Result result = ru.delete();
       assertThat(result).isEqualTo(Result.FORCED);
@@ -109,7 +116,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -123,8 +130,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-              RefNames.refsGroups(new AccountGroup.UUID(BOGUS_UUID)));
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)),
+              RefNames.refsGroups(AccountGroup.uuid(BOGUS_UUID)));
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -135,7 +142,7 @@
   @Test
   public void groupRefDoesNotParse() throws Exception {
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)),
         GroupConfig.GROUP_CONFIG_FILE,
         "[this is not valid\n");
     assertError("does not parse");
@@ -145,7 +152,7 @@
   public void nameRefDoesNotParse() throws Exception {
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g1.name)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(g1.name)).getName(),
         "[this is not valid\n");
     assertError("does not parse");
   }
@@ -158,9 +165,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("inconsistent name");
   }
 
@@ -172,9 +177,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("shared group id");
   }
 
@@ -186,9 +189,7 @@
     cfg.setString("group", null, "ownerGroupUuid", BOGUS_UUID);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("nonexistent owner group");
   }
 
@@ -201,27 +202,26 @@
 
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(bogusName)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(bogusName)).getName(),
         config.toText());
     assertError("entry missing as group ref");
   }
 
   @Test
   public void nonexistentMember() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "members", "314159265\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "members", "314159265\n");
     assertError("nonexistent member 314159265");
   }
 
   @Test
   public void nonexistentSubgroup() throws Exception {
-    updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", BOGUS_UUID + "\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", BOGUS_UUID + "\n");
     assertError("has nonexistent subgroup");
   }
 
   @Test
   public void cyclicSubgroup() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", g1.id + "\n");
     assertWarning("cycle");
   }
 
@@ -252,7 +252,8 @@
       }
     }
 
-    fail(String.format("could not find %s substring '%s' in %s", want, msg, problems));
+    assertWithMessage(String.format("could not find %s substring '%s' in %s", want, msg, problems))
+        .fail();
   }
 
   private void updateGroupFile(String refName, String fileName, String content) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 47ac7a9..8f53393 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -21,19 +21,25 @@
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -41,13 +47,19 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -60,18 +72,12 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.group.InternalGroup;
@@ -85,11 +91,9 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.lang.annotation.Retention;
@@ -100,7 +104,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -117,33 +120,24 @@
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
+@UseClockStep
 public class GroupsIT extends AbstractDaemonTest {
   @Inject @ServerInitiated private GroupsUpdate groupsUpdate;
   @Inject private AccountOperations accountOperations;
-  @Inject private DynamicSet<GroupIndexedListener> groupIndexedListeners;
   @Inject private GroupIncludeCache groupIncludeCache;
   @Inject private GroupIndexer groupIndexer;
   @Inject private GroupOperations groupOperations;
   @Inject private Groups groups;
   @Inject private GroupsConsistencyChecker consistencyChecker;
   @Inject private PeriodicGroupIndexer slaveGroupIndexer;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
-
-  @Before
-  public void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-  }
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @After
   public void consistencyCheck() throws Exception {
@@ -168,14 +162,16 @@
 
   @Test
   public void addToNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").addMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").addMembers("admin"));
   }
 
   @Test
   public void removeFromNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").removeMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").removeMembers("admin"));
   }
 
   @Test
@@ -215,7 +211,7 @@
   @Test
   public void cachedGroupByNameIsUpdatedOnCreation() throws Exception {
     String newGroupName = name("newGroup");
-    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(newGroupName);
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(newGroupName);
     assertThat(groupCache.get(nameKey)).isEmpty();
     gApi.groups().create(newGroupName);
     assertThat(groupCache.get(nameKey)).isPresent();
@@ -231,8 +227,9 @@
 
   @Test
   public void addNonExistingMember_UnprocessableEntity() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id("Administrators").addMembers("non-existing");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id("Administrators").addMembers("non-existing"));
   }
 
   @Test
@@ -351,9 +348,9 @@
   public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
     String dupGroupName = name("dupGroup");
     gApi.groups().create(dupGroupName);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + dupGroupName + "' already exists");
-    gApi.groups().create(dupGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(dupGroupName));
+    assertThat(thrown).hasMessageThat().contains("group '" + dupGroupName + "' already exists");
   }
 
   @Test
@@ -369,33 +366,71 @@
   @Test
   public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
     String newGroupName = "Registered Users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
     String newGroupName = "registered users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void createGroupWithConfiguredNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'All Users' already exists");
-    gApi.groups().create("all users");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create("all users"));
+    assertThat(thrown).hasMessageThat().contains("group 'All Users' already exists");
   }
 
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void createGroupWithDefaultNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group name 'Anonymous Users' is reserved");
-    gApi.groups().create("anonymous users");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.groups().create("anonymous users"));
+    assertThat(thrown).hasMessageThat().contains("group name 'Anonymous Users' is reserved");
+  }
+
+  @Test
+  public void createGroupWithUuid() throws Exception {
+    AccountGroup.UUID uuid = AccountGroup.UUID.parse("4eb25d1cca562f53b9356117f33840706a36a349");
+    GroupInput input = new GroupInput();
+    input.uuid = uuid.get();
+    input.name = name("new-group");
+    GroupInfo info = gApi.groups().create(input).get();
+    assertThat(info.name).isEqualTo(input.name);
+    assertThat(info.id).isEqualTo(input.uuid);
+  }
+
+  @Test
+  public void createGroupWithExistingUuid_Conflict() throws Exception {
+    GroupInfo existingGroup = gApi.groups().create(name("new-group")).get();
+    GroupInput input = new GroupInput();
+    input.uuid = existingGroup.id;
+    input.name = name("another-new-group");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(input).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("group with UUID '%s' already exists", input.uuid));
+  }
+
+  @Test
+  public void createGroupWithInvalidUuid_BadRequest() throws Exception {
+    AccountGroup.UUID uuid = AccountGroup.UUID.parse("foo:bar");
+    GroupInput input = new GroupInput();
+    input.uuid = uuid.get();
+    input.name = name("new-group");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.groups().create(input).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("invalid group UUID '%s'", input.uuid));
   }
 
   @Test
@@ -414,8 +449,7 @@
   @Test
   public void createGroupWithoutCapability_Forbidden() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.groups().create(name("newGroup"));
+    assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
   @Test
@@ -441,7 +475,7 @@
     GroupInfo group = gApi.groups().create(groupInput).get();
 
     Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
-    assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
+    assertThat(groups).containsExactly(AccountGroup.uuid(group.id));
   }
 
   @Test
@@ -481,8 +515,7 @@
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void getSystemGroupByDefaultName_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("Anonymous-Users").get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("Anonymous-Users").get());
   }
 
   @Test
@@ -498,8 +531,7 @@
     String name = name("Users");
     gApi.groups().create(name).get();
 
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().create(name);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().create(name));
   }
 
   @Test
@@ -528,9 +560,7 @@
 
     String name2 = name("Name2");
     gApi.groups().create(name2);
-
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().id(group1.id).name(name2);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().id(group1.id).name(name2));
   }
 
   @Test
@@ -554,8 +584,7 @@
     gApi.groups().id(group.id).name(newName);
 
     assertGroupDoesNotExist(name);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id(name).get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(name).get());
   }
 
   @Test
@@ -626,14 +655,15 @@
     assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
 
     // set non existing owner
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(name).owner("Non-Existing Group");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(name).owner("Non-Existing Group"));
   }
 
   @Test
   public void listNonExistingGroupIncludes_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").includedGroups();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").includedGroups());
   }
 
   @Test
@@ -645,8 +675,9 @@
   @Test
   public void includeNonExistingGroup() throws Exception {
     AccountGroup.UUID gx = groupOperations.newGroup().create();
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(gx.get()).addGroups("non-existing");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(gx.get()).addGroups("non-existing"));
   }
 
   @Test
@@ -675,8 +706,7 @@
 
   @Test
   public void listNonExistingGroupMembers_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").members();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").members());
   }
 
   @Test
@@ -806,13 +836,13 @@
 
     // By UUID
     List<GroupInfo> owned = gApi.groups().list().withOwnedBy(parent.get()).get();
-    assertThat(owned.stream().map(g -> new AccountGroup.UUID(g.id)).collect(toList()))
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By name
     String parentName = groupOperations.group(parent).get().name();
     owned = gApi.groups().list().withOwnedBy(parentName).get();
-    assertThat(owned.stream().map(g -> new AccountGroup.UUID(g.id)).collect(toList()))
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By group that does not own any others
@@ -820,9 +850,11 @@
     assertThat(owned).isEmpty();
 
     // By non-existing group
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Group Not Found: does-not-exist");
-    gApi.groups().list().withOwnedBy("does-not-exist").get();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.groups().list().withOwnedBy("does-not-exist").get());
+    assertThat(thrown).hasMessageThat().contains("Group Not Found: does-not-exist");
   }
 
   @Test
@@ -984,7 +1016,7 @@
   }
 
   private void deleteGroupRef(String groupId) throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(groupId);
+    AccountGroup.UUID uuid = AccountGroup.uuid(groupId);
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefUpdate ru = repo.updateRef(RefNames.refsGroups(uuid));
       ru.setForceUpdate(true);
@@ -996,11 +1028,7 @@
     gApi.groups().id(uuid.get()).index();
 
     // Verify "sub-group" has been deleted.
-    try {
-      gApi.groups().id(uuid.get()).get();
-      fail("expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-    }
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(uuid.get()).get());
   }
 
   // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
@@ -1023,9 +1051,9 @@
 
     // user cannot reindex any group
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index group");
-    gApi.groups().id(group.id).index();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.groups().id(group.id).index());
+    assertThat(thrown).hasMessageThat().contains("not allowed to index group");
   }
 
   @Test
@@ -1037,8 +1065,7 @@
   @Test
   public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(allUsers, groupRef);
     assertPushToGroupBranch(allUsers, groupRef, "group update not allowed");
   }
@@ -1046,7 +1073,10 @@
   @Test
   public void pushToGroupNamesBranchIsRejectedForAllUsersRepo() throws Exception {
     // refs/meta/group-names isn't usually available for fetch, so grant ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     assertPushToGroupBranch(allUsers, RefNames.REFS_GROUPNAMES, "group update not allowed");
   }
 
@@ -1054,7 +1084,7 @@
   public void pushToGroupsBranchForNonAllUsersRepo() throws Exception {
     assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
@@ -1063,8 +1093,7 @@
   public void pushToDeletedGroupsBranchForNonAllUsersRepo() throws Exception {
     assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
@@ -1077,15 +1106,18 @@
 
   private void assertPushToGroupBranch(
       Project.NameKey project, String groupRefName, String expectedErrorOnUpdate) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_DELETED_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_DELETED_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPNAMES);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(
+            allow(Permission.CREATE)
+                .ref(RefNames.REFS_DELETED_GROUPS + "*")
+                .group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_DELETED_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPNAMES).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
 
@@ -1104,12 +1136,12 @@
   }
 
   private void assertCreateGroupBranch(Project.NameKey project) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     PushOneCommit.Result r =
         pushFactory
@@ -1120,13 +1152,13 @@
   }
 
   @Test
-  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Throwable {
     pushToGroupBranchForReviewAndSubmit(
         allUsers, RefNames.refsGroups(adminGroupUuid()), "group update not allowed");
   }
 
   @Test
-  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Throwable {
     String groupRef = RefNames.refsGroups(adminGroupUuid());
     createBranch(project, groupRef);
     pushToGroupBranchForReviewAndSubmit(project, groupRef, null);
@@ -1160,14 +1192,14 @@
   @Test
   public void cannotCreateGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
-        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
   public void cannotCreateDeletedGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
         RefNames.REFS_DELETED_GROUPS + "*",
-        RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
@@ -1190,15 +1222,22 @@
       }
 
       // refs/meta/group-names is only visible with ACCESS_DATABASE
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
 
       testCannotCreateGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
     }
   }
 
   private void testCannotCreateGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.CREATE);
-    grant(allUsers, refPattern, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(refPattern).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(refPattern).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(groupRef);
@@ -1217,7 +1256,7 @@
 
   @Test
   public void cannotDeleteDeletedGroupBranch() throws Exception {
-    String groupRef = RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo")));
+    String groupRef = RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo")));
     createBranch(allUsers, groupRef);
     testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
   }
@@ -1225,13 +1264,20 @@
   @Test
   public void cannotDeleteGroupNamesBranch() throws Exception {
     // refs/meta/group-names is only visible with ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     testCannotDeleteGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
   }
 
   private void testCannotDeleteGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref(refPattern).group(REGISTERED_USERS).force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushResult r = deleteRef(allUsersRepo, groupRef);
@@ -1255,7 +1301,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created group is not stale
     GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupInfo.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(groupInfo.id);
     assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
 
     // Manual update makes index document stale
@@ -1325,9 +1371,7 @@
     restartAsSlave();
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
-    RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add("gerrit", groupIndexedCounter);
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(groupIndexedCounter)) {
       // Running the reindexer right after startup should not need to reindex any group since
       // reindexing was already done on startup.
       slaveGroupIndexer.run();
@@ -1336,12 +1380,12 @@
       // Create a group without updating the cache or index,
       // then run the reindexer -> only the new group is reindexed.
       String groupName = "foo";
-      AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupName + "-UUID");
+      AccountGroup.UUID groupUuid = AccountGroup.uuid(groupName + "-UUID");
       groupsUpdate.createGroupInNoteDb(
           InternalGroupCreation.builder()
               .setGroupUUID(groupUuid)
-              .setNameKey(new AccountGroup.NameKey(groupName))
-              .setId(new AccountGroup.Id(seq.nextGroupId()))
+              .setNameKey(AccountGroup.nameKey(groupName))
+              .setId(AccountGroup.id(seq.nextGroupId()))
               .build(),
           InternalGroupUpdate.builder().build());
       slaveGroupIndexer.run();
@@ -1363,8 +1407,6 @@
       }
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
-    } finally {
-      groupIndexEventCounterHandle.remove();
     }
   }
 
@@ -1382,25 +1424,18 @@
     restartAsSlave();
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
-    RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add("gerrit", groupIndexedCounter);
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(groupIndexedCounter)) {
       // No group indexing happened on startup. All groups should be reindexed now.
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(expectedGroups);
-    } finally {
-      groupIndexEventCounterHandle.remove();
     }
   }
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<AccountInfo, String>() {
-          @Override
-          public boolean apply(AccountInfo actualAccount, String expectedName) {
-            String username = actualAccount == null ? null : actualAccount.username;
-            return Objects.equals(username, expectedName);
-          }
+        (actualAccount, expectedName) -> {
+          String username = actualAccount == null ? null : actualAccount.username;
+          return Objects.equals(username, expectedName);
         },
         "has username");
   }
@@ -1416,10 +1451,17 @@
   }
 
   private void pushToGroupBranchForReviewAndSubmit(
-      Project.NameKey project, String groupRef, String expectedError) throws Exception {
-    grantLabel(
-        "Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", false, REGISTERED_USERS, false);
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.SUBMIT, false, REGISTERED_USERS);
+      Project.NameKey project, String groupRef, String expectedError) throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(RefNames.REFS_GROUPS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     fetch(repo, groupRef + ":groupRef");
@@ -1430,14 +1472,16 @@
             .create(admin.newIdent(), repo, "Update group config", "group.config", "some content")
             .to(MagicBranch.NEW_CHANGE + groupRef);
     r.assertOkStatus();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(groupRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(groupRef);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
+    ThrowingRunnable submit = () -> gApi.changes().id(r.getChangeId()).current().submit();
     if (expectedError != null) {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("group update not allowed");
+      Throwable thrown = assertThrows(ResourceConflictException.class, submit);
+      assertThat(thrown).hasMessageThat().contains("group update not allowed");
+    } else {
+      submit.run();
     }
-    gApi.changes().id(r.getChangeId()).current().submit();
   }
 
   private void createBranch(Project.NameKey project, String ref) throws IOException {
@@ -1514,16 +1558,11 @@
   private static void assertIncludes(List<GroupInfo> includes, String... expectedNames) {
     List<String> names = includes.stream().map(i -> i.name).collect(toImmutableList());
     assertThat(names).containsExactlyElementsIn(Arrays.asList(expectedNames));
-    assertThat(names).isOrdered();
+    assertThat(names).isInOrder();
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 
   @Target({METHOD})
@@ -1543,24 +1582,18 @@
       countsByGroup.clear();
     }
 
-    long getCount(AccountGroup.UUID groupUuid) {
-      return countsByGroup.get(groupUuid.get());
-    }
-
     void assertReindexOf(AccountGroup.UUID groupUuid) {
       assertReindexOf(ImmutableList.of(groupUuid));
     }
 
     void assertReindexOf(List<AccountGroup.UUID> groupUuids) {
-      for (AccountGroup.UUID groupUuid : groupUuids) {
-        assertThat(getCount(groupUuid)).named(groupUuid.get()).isEqualTo(1);
-      }
-      assertThat(countsByGroup).hasSize(groupUuids.size());
+      Map<String, Long> expected = groupUuids.stream().collect(toMap(u -> u.get(), u -> 1L));
+      assertThat(countsByGroup.asMap()).containsExactlyEntriesIn(expected);
       clear();
     }
 
     void assertNoReindex() {
-      assertThat(countsByGroup).isEmpty();
+      assertThat(countsByGroup.asMap()).isEmpty();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 5e143c0..6fcca8c 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -36,12 +37,9 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class GroupsUpdateIT {
   @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
   @Inject private Groups groups;
 
@@ -56,7 +54,7 @@
     createGroup(groupCreation, groupUpdate);
 
     Stream<String> allGroupNames = getAllGroupNames();
-    assertThat(allGroupNames).containsAllOf("users", "verifiers");
+    assertThat(allGroupNames).containsAtLeast("users", "verifiers");
   }
 
   @Test
@@ -65,23 +63,23 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("contributors"))
+            .setName(AccountGroup.nameKey("contributors"))
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    updateGroup(new AccountGroup.UUID("users-UUID"), groupUpdate);
+    updateGroup(AccountGroup.uuid("users-UUID"), groupUpdate);
 
     Stream<String> allGroupNames = getAllGroupNames();
-    assertThat(allGroupNames).containsAllOf("contributors", "verifiers");
+    assertThat(allGroupNames).containsAtLeast("contributors", "verifiers");
   }
 
   @Test
   public void groupUpdateFailsWithExceptionForNotExistingGroup() throws Exception {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("A description for the group").build();
-
-    expectedException.expect(NoSuchGroupException.class);
-    updateGroup(new AccountGroup.UUID("nonexistent-group-UUID"), groupUpdate);
+    assertThrows(
+        NoSuchGroupException.class,
+        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupUpdate));
   }
 
   private void createGroup(String groupName, String groupUuid) throws Exception {
@@ -107,9 +105,9 @@
 
   private static InternalGroupCreation getGroupCreation(String groupName, String groupUuid) {
     return InternalGroupCreation.builder()
-        .setGroupUUID(new AccountGroup.UUID(groupUuid))
-        .setNameKey(new AccountGroup.NameKey(groupName))
-        .setId(new AccountGroup.Id(Math.abs(groupName.hashCode())))
+        .setGroupUUID(AccountGroup.uuid(groupUuid))
+        .setNameKey(AccountGroup.nameKey(groupName))
+        .setId(AccountGroup.id(Math.abs(groupName.hashCode())))
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 1d3eb17..a120eac 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.plugin;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -34,6 +35,7 @@
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.plugins.MandatoryPluginsCollection;
 import com.google.inject.Inject;
 import java.util.List;
 import org.junit.Test;
@@ -52,6 +54,7 @@
           "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
 
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private MandatoryPluginsCollection mandatoryPluginsCollection;
 
   @Test
   @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
@@ -98,7 +101,13 @@
     assertBadRequest(list().regex(".*in-b").prefix("a"));
     assertBadRequest(list().substring(".*in-b").prefix("a"));
 
-    // Disable
+    // Disable mandatory
+    mandatoryPluginsCollection.add("plugin_e");
+    assertThrows(MethodNotAllowedException.class, () -> gApi.plugins().name("plugin_e").disable());
+    api = gApi.plugins().name("plugin_e");
+    assertThat(api.get().disabled).isNull();
+
+    // Disable non-mandatory
     api = gApi.plugins().name("plugin-a");
     api.disable();
     api = gApi.plugins().name("plugin-a");
@@ -117,12 +126,7 @@
 
     // Non-admin cannot disable
     requestScopeOperations.setApiUser(user.id());
-    try {
-      gApi.plugins().name("plugin-a").disable();
-      fail("Expected AuthException");
-    } catch (AuthException expected) {
-      // Expected
-    }
+    assertThrows(AuthException.class, () -> gApi.plugins().name("plugin-a").disable());
   }
 
   @SuppressWarnings("deprecation")
@@ -136,15 +140,16 @@
 
   @Test
   public void installNotAllowed() throws Exception {
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote plugin administration is disabled");
-    gApi.plugins().install("test.js", new InstallPluginInput());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.plugins().install("test.js", new InstallPluginInput()));
+    assertThat(thrown).hasMessageThat().contains("remote plugin administration is disabled");
   }
 
   @Test
   public void getNonExistingThrowsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.plugins().name("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.plugins().name("does-not-exist"));
   }
 
   private ListRequest list() throws RestApiException {
@@ -170,11 +175,6 @@
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
new file mode 100644
index 0000000..7eb3680
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.plugin;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.server.plugins.MissingMandatoryPluginsException;
+import org.junit.Test;
+import org.junit.runner.Description;
+
+@NoHttpd
+public class PluginLoaderIT extends AbstractDaemonTest {
+
+  Description testDescription;
+
+  @Override
+  protected void beforeTest(Description description) throws Exception {
+    this.testDescription = description;
+  }
+
+  @Override
+  protected void afterTest() throws Exception {}
+
+  @Test(expected = MissingMandatoryPluginsException.class)
+  @GerritConfig(name = "plugins.mandatory", value = "my-mandatory-plugin")
+  public void shouldFailToStartGerritWhenMandatoryPluginsAreMissing() throws Exception {
+    super.beforeTest(testDescription);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 8cdd2f66..b8c1818 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -15,6 +15,11 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -23,17 +28,15 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -62,36 +65,41 @@
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
     groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
 
-    try (ProjectConfigUpdate u = updateProject(secretProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.READ, privilegedGroupUuid, "refs/*");
-      Util.block(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(secretProject)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
-    try (ProjectConfigUpdate u = updateProject(secretRefProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.deny(cfg, Permission.READ, SystemGroupBackend.ANONYMOUS_USERS, "refs/*");
-      Util.allow(cfg, Permission.READ, privilegedGroupUuid, "refs/heads/secret/*");
-      Util.block(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/heads/secret/*");
-      Util.allow(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(secretRefProject)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/secret/*").group(privilegedGroupUuid))
+        .add(
+            block(Permission.READ)
+                .ref("refs/heads/secret/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
     // Ref permission
-    try (ProjectConfigUpdate u = updateProject(normalProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.VIEW_PRIVATE_CHANGES, privilegedGroupUuid, "refs/*");
-      Util.allow(cfg, Permission.FORGE_SERVER, privilegedGroupUuid, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(normalProject)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(privilegedGroupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref("refs/*").group(privilegedGroupUuid))
+        .update();
   }
 
   @Test
   public void emptyInput() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input requires 'account'");
-    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("input requires 'account'");
   }
 
   @Test
@@ -101,9 +109,11 @@
     in.permission = "notapermission";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("not recognized");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("not recognized");
   }
 
   @Test
@@ -112,9 +122,11 @@
     in.account = user.email();
     in.permission = "forge_author";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("must set 'ref'");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("must set 'ref'");
   }
 
   @Test
@@ -124,9 +136,11 @@
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("recognized as ref permission");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("recognized as ref permission");
   }
 
   @Test
@@ -136,9 +150,11 @@
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account 'doesnotexist@invalid.com' not found");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("Account 'doesnotexist@invalid.com' not found");
   }
 
   private static class TestCase {
@@ -231,13 +247,16 @@
       try {
         info = gApi.projects().name(tc.project).checkAccess(tc.input);
       } catch (RestApiException e) {
-        fail(String.format("check.access(%s, %s): exception %s", tc.project, in, e));
+        assertWithMessage(String.format("check.access(%s, %s): exception %s", tc.project, in, e))
+            .fail();
       }
 
       int want = tc.want;
       if (want != info.status) {
-        fail(
-            String.format("check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want));
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want))
+            .fail();
       }
 
       switch (want) {
@@ -253,7 +272,7 @@
           assertThat(info.message).isNull();
           break;
         default:
-          fail(String.format("unknown code %d", want));
+          assertWithMessage(String.format("unknown code %d", want)).fail();
       }
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
index 388ea30..27dd16a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
@@ -50,7 +51,7 @@
   @Test
   public void noProblem() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
@@ -115,7 +116,7 @@
   @Test
   public void detectAutoCloseableChangeByChangeId() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -138,7 +139,7 @@
   @Test
   public void fixAutoCloseableChangeByChangeId() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -162,7 +163,7 @@
   @Test
   public void maxCommits() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -196,7 +197,7 @@
   @Test
   public void skipCommits() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -232,18 +233,21 @@
     CheckProjectInput input = new CheckProjectInput();
     input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branch is required");
-    gApi.projects().name(project.get()).check(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch is required");
   }
 
   @Test
   public void nonExistingBranch() throws Exception {
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("branch 'non-existing' not found");
-    gApi.projects().name(project.get()).check(input);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch 'non-existing' not found");
   }
 
   @Test
@@ -266,11 +270,14 @@
     input.autoCloseableChangesCheck.maxCommits =
         ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT + 1;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "max commits can at most be set to "
-            + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
-    gApi.projects().name(project.get()).check(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "max commits can at most be set to "
+                + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
   }
 
   private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
@@ -280,10 +287,12 @@
             .branch("HEAD")
             .commit()
             .message("A change")
+            .insertChangeId()
             .author(admin.newIdent())
             .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()))
             .create();
     pushHead(testRepo, "refs/for/master");
+
     return commit;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index a341b3c..04625c5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
@@ -22,7 +23,9 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -33,7 +36,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,6 +45,8 @@
 
 @NoHttpd
 public class CommitIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void getCommitInfo() throws Exception {
     Result result = createChange();
@@ -75,22 +80,46 @@
     assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
     assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
 
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
 
     assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
 
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+    createBranch(BranchNameKey.create(project, "test-branch"));
 
     assertThat(getIncludedIn(result.getCommit().getId()).branches)
         .containsExactly("master", "test-branch");
   }
 
   @Test
+  public void cherryPickWithoutMessage() throws Exception {
+    String branch = "foo";
+
+    // Create change to cherry-pick
+    RevCommit revCommit = createChange().getCommit();
+
+    // Create target branch to cherry-pick to.
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    // Cherry-pick without message.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = branch;
+    String changeId =
+        gApi.projects().name(project.get()).commit(revCommit.name()).cherryPick(input).get().id;
+
+    // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(revCommit.getFullMessage());
+  }
+
+  @Test
   public void cherryPickCommitWithoutChangeId() throws Exception {
-    // This test is a little superfluous, since the current cherry-pick code ignores
-    // the commit message of the to-be-cherry-picked change, using the one in
-    // CherryPickInput instead.
     CherryPickInput input = new CherryPickInput();
     input.destination = "foo";
     input.message = "it goes to foo branch";
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index f597392..6442645 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -15,12 +15,15 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
@@ -31,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.restapi.project.DashboardsCollection;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -40,21 +44,25 @@
 
 @NoHttpd
 public class DashboardIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Before
   public void setup() throws Exception {
-    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/dashboards/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @Test
   public void defaultDashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
   public void dashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().dashboard("my:dashboard").get();
+    assertThrows(ResourceNotFoundException.class, () -> project().dashboard("my:dashboard").get());
   }
 
   @Test
@@ -110,8 +118,7 @@
     project().removeDefaultDashboard();
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
@@ -133,9 +140,9 @@
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
     DashboardInfo info = createTestDashboard();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("inherited flag can only be used with default");
-    project().dashboard(info.id).get(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().dashboard(info.id).get(true));
+    assertThat(thrown).hasMessageThat().contains("inherited flag can only be used with default");
   }
 
   private void assertDashboardInfo(DashboardInfo actual, DashboardInfo expected) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 2b6cd08..0ea7284 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,16 +15,24 @@
 package com.google.gerrit.acceptance.api.project;
 
 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.block;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -32,6 +40,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -44,15 +55,12 @@
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.CommentLinkInfoImpl;
@@ -64,8 +72,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -77,12 +83,9 @@
   private static final String JIRA_LINK = "http://jira.example.com/?id=$2";
   private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
 
-  @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-
-  private ProjectIndexedCounter projectIndexedCounter;
-  private RegistrationHandle projectIndexedCounterHandle;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Override
   public Module createModule() {
@@ -96,53 +99,49 @@
     };
   }
 
-  @Before
-  public void addProjectIndexedCounter() {
-    projectIndexedCounter = new ProjectIndexedCounter();
-    projectIndexedCounterHandle = projectIndexedListeners.add("gerrit", projectIndexedCounter);
-  }
+  @Test
+  public void createProject() throws Exception {
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
+      String name = name("foo");
+      assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
 
-  @After
-  public void removeProjectIndexedCounter() {
-    if (projectIndexedCounterHandle != null) {
-      projectIndexedCounterHandle.remove();
+      RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+      eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+
+      eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", new String[] {});
+      projectIndexedCounter.assertReindexOf(name);
     }
   }
 
   @Test
-  public void createProject() throws Exception {
-    String name = name("foo");
-    assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
-
-    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
-
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", new String[] {});
-    projectIndexedCounter.assertReindexOf(name);
-  }
-
-  @Test
   public void createProjectWithInitialBranches() throws Exception {
-    String name = name("foo");
-    ProjectInput input = new ProjectInput();
-    input.name = name;
-    input.createEmptyCommit = true;
-    input.branches = ImmutableList.of("master", "foo");
-    assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
-    assertThat(
-            gApi.projects().name(name).branches().get().stream().map(b -> b.ref).collect(toSet()))
-        .containsExactly("refs/heads/foo", "refs/heads/master", "HEAD", RefNames.REFS_CONFIG);
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
 
-    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+      String name = name("foo");
+      ProjectInput input = new ProjectInput();
+      input.name = name;
+      input.createEmptyCommit = true;
+      input.branches = ImmutableList.of("master", "foo");
+      assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
+      assertThat(
+              gApi.projects().name(name).branches().get().stream().map(b -> b.ref).collect(toSet()))
+          .containsExactly("refs/heads/foo", "refs/heads/master", "HEAD", RefNames.REFS_CONFIG);
 
-    head = getRemoteHead(name, "refs/heads/foo");
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/foo", null, head);
+      RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+      eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
-    head = getRemoteHead(name, "refs/heads/master");
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
+      head = getRemoteHead(name, "refs/heads/foo");
+      eventRecorder.assertRefUpdatedEvents(name, "refs/heads/foo", null, head);
 
-    projectIndexedCounter.assertReindexOf(name);
+      head = getRemoteHead(name, "refs/heads/master");
+      eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
+
+      projectIndexedCounter.assertReindexOf(name);
+    }
   }
 
   @Test
@@ -186,17 +185,17 @@
   public void createProjectWithMismatchedInput() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name must match input.name");
-    gApi.projects().name("bar").create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().name("bar").create(in));
+    assertThat(thrown).hasMessageThat().contains("name must match input.name");
   }
 
   @Test
   public void createProjectNoNameInInput() throws Exception {
     ProjectInput in = new ProjectInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input.name is required");
-    gApi.projects().create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("input.name is required");
   }
 
   @Test
@@ -204,9 +203,9 @@
     ProjectInput in = new ProjectInput();
     in.name = name("baz");
     gApi.projects().create(in);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Project already exists");
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project already exists");
   }
 
   @Test
@@ -215,9 +214,9 @@
     in.name = name("baz");
     in.parent = "non-existing";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -226,9 +225,9 @@
     in.name = name("baz");
     in.parent = in.name;
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -236,52 +235,67 @@
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
     in.parent = allUsers.get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
   public void createAndDeleteBranch() throws Exception {
-    assertThat(hasHead(project, "foo")).isFalse();
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
 
-    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
-    assertThat(getRemoteHead(project.get(), "foo")).isNotNull();
-    projectIndexedCounter.assertNoReindex();
+      assertThat(hasHead(project, "foo")).isFalse();
 
-    gApi.projects().name(project.get()).branch("foo").delete();
-    assertThat(hasHead(project, "foo")).isFalse();
-    projectIndexedCounter.assertNoReindex();
+      gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
+      assertThat(getRemoteHead(project.get(), "foo")).isNotNull();
+      projectIndexedCounter.assertNoReindex();
+
+      gApi.projects().name(project.get()).branch("foo").delete();
+      assertThat(hasHead(project, "foo")).isFalse();
+      projectIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void createAndDeleteBranchByPush() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
-    projectIndexedCounter.clear();
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+          .update();
+      projectIndexedCounter.clear();
 
-    assertThat(hasHead(project, "foo")).isFalse();
+      assertThat(hasHead(project, "foo")).isFalse();
 
-    PushOneCommit.Result r = pushTo("refs/heads/foo");
-    r.assertOkStatus();
-    assertThat(getRemoteHead(project.get(), "foo")).isEqualTo(r.getCommit());
-    projectIndexedCounter.assertNoReindex();
+      PushOneCommit.Result r = pushTo("refs/heads/foo");
+      r.assertOkStatus();
+      assertThat(getRemoteHead(project.get(), "foo")).isEqualTo(r.getCommit());
+      projectIndexedCounter.assertNoReindex();
 
-    PushResult r2 = GitUtil.pushOne(testRepo, null, "refs/heads/foo", false, true, null);
-    assertThat(r2.getRemoteUpdate("refs/heads/foo").getStatus()).isEqualTo(Status.OK);
-    assertThat(hasHead(project, "foo")).isFalse();
-    projectIndexedCounter.assertNoReindex();
+      PushResult r2 = GitUtil.pushOne(testRepo, null, "refs/heads/foo", false, true, null);
+      assertThat(r2.getRemoteUpdate("refs/heads/foo").getStatus()).isEqualTo(Status.OK);
+      assertThat(hasHead(project, "foo")).isFalse();
+      projectIndexedCounter.assertNoReindex();
+    }
   }
 
   @Test
   public void descriptionChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
     gApi.projects().name(project.get()).description(in);
     assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -300,7 +314,7 @@
 
   @Test
   public void configChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
 
     ConfigInfo info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
@@ -311,7 +325,7 @@
     info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -375,9 +389,9 @@
   public void nonOwnerCannotSetConfig() throws Exception {
     ConfigInput input = createTestConfigInput();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("write refs/meta/config not permitted");
-    gApi.projects().name(project.get()).config(input);
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).config(input));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
   @Test
@@ -393,8 +407,9 @@
 
   @Test
   public void setHeadToNonexistentBranch() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.projects().name(project.get()).head("does-not-exist");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.projects().name(project.get()).head("does-not-exist"));
   }
 
   @Test
@@ -410,9 +425,9 @@
   public void setHeadNotAllowed() throws Exception {
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: set HEAD on refs/heads/test");
-    gApi.projects().name(project.get()).head("test");
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).head("test"));
+    assertThat(thrown).hasMessageThat().contains("not permitted: set HEAD on refs/heads/test");
   }
 
   @Test
@@ -442,8 +457,18 @@
     assertThat(gApi.projects().name(project.get()).config().state).isEqualTo(ProjectState.HIDDEN);
 
     // Revoke OWNER permission for admin and block them from reading the project's refs
-    block(project, RefNames.REFS + "*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
-    block(project, RefNames.REFS + "*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.OWNER)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(
+            block(Permission.READ)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
     // HIDDEN => ACTIVE
     ConfigInput ci2 = new ConfigInput();
@@ -455,22 +480,49 @@
 
   @Test
   public void reindexProject() throws Exception {
-    projectOperations.newProject().parent(project).create();
-    projectIndexedCounter.clear();
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
 
-    gApi.projects().name(allProjects.get()).index(false);
-    projectIndexedCounter.assertReindexOf(allProjects.get());
+      projectOperations.newProject().parent(project).create();
+      projectIndexedCounter.clear();
+
+      gApi.projects().name(allProjects.get()).index(false);
+      projectIndexedCounter.assertReindexOf(allProjects.get());
+    }
   }
 
   @Test
   public void reindexProjectWithChildren() throws Exception {
-    Project.NameKey middle = projectOperations.newProject().parent(project).create();
-    Project.NameKey leave = projectOperations.newProject().parent(middle).create();
-    projectIndexedCounter.clear();
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
 
-    gApi.projects().name(project.get()).index(true);
-    projectIndexedCounter.assertReindexExactly(
-        ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
+      Project.NameKey middle = projectOperations.newProject().parent(project).create();
+      Project.NameKey leave = projectOperations.newProject().parent(middle).create();
+      projectIndexedCounter.clear();
+
+      gApi.projects().name(project.get()).index(true);
+      projectIndexedCounter.assertReindexExactly(
+          ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
+    }
+  }
+
+  @Test
+  public void reindexChangesOfProject() throws Exception {
+    Change.Id changeId1 = createChange().getChange().getId();
+    Change.Id changeId2 = createChange().getChange().getId();
+
+    ChangeIndexedListener changeIndexedListener = mock(ChangeIndexedListener.class);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedListener)) {
+      gApi.projects().name(project.get()).indexChanges();
+
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), changeId1.get());
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), changeId2.get());
+    }
   }
 
   @Test
@@ -635,9 +687,9 @@
 
   @Test
   public void invalidMaxObjectSizeIsRejected() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("100 foo");
-    setMaxObjectSize("100 foo");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> setMaxObjectSize("100 foo"));
+    assertThat(thrown).hasMessageThat().contains("100 foo");
   }
 
   @Test
@@ -726,22 +778,17 @@
       countsByProject.clear();
     }
 
-    long getCount(String projectName) {
-      return countsByProject.get(projectName);
-    }
-
     void assertReindexOf(String projectName) {
       assertReindexOf(projectName, 1);
     }
 
-    void assertReindexOf(String projectName, int expectedCount) {
-      assertThat(getCount(projectName)).isEqualTo(expectedCount);
-      assertThat(countsByProject).hasSize(1);
+    void assertReindexOf(String projectName, long expectedCount) {
+      assertThat(countsByProject.asMap()).containsExactly(projectName, expectedCount);
       clear();
     }
 
     void assertNoReindex() {
-      assertThat(countsByProject).isEmpty();
+      assertThat(countsByProject.asMap()).isEmpty();
     }
 
     void assertReindexExactly(ImmutableMap<String, Long> expected) {
@@ -751,7 +798,7 @@
   }
 
   protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(new Project.NameKey(project), branch);
+    return projectOperations.project(Project.nameKey(project)).getHead(branch);
   }
 
   boolean hasHead(Project.NameKey k, String b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index 6b511f6..019df0e 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -15,11 +15,14 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.RefState;
@@ -28,7 +31,6 @@
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.project.StalenessChecker;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
@@ -60,7 +62,7 @@
     Optional<FieldBundle> result =
         i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
 
-    assertThat(result.isPresent()).isTrue();
+    assertThat(result).isPresent();
     Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
     assertThat(refState).isNotEmpty();
 
@@ -117,15 +119,15 @@
 
   private void updateProjectConfigWithoutIndexUpdate(
       Project.NameKey project, Consumer<ProjectConfig> update) throws Exception {
-    try (AutoCloseable ignored = disableProjectIndex()) {
-      try (ProjectConfigUpdate u = updateProject(project)) {
-        update.accept(u.getConfig());
-        u.save();
-      }
-    } catch (UnsupportedOperationException e) {
-      // Drop, as we just wanted to drop the index update
-      return;
-    }
-    fail("should have a UnsupportedOperationException");
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> {
+          try (AutoCloseable ignored = disableProjectIndex()) {
+            try (ProjectConfigUpdate u = updateProject(project)) {
+              update.accept(u.getConfig());
+              u.save();
+            }
+          }
+        });
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 3c1428d..cf7aab4 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -22,11 +24,11 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
@@ -34,7 +36,6 @@
 
 @NoHttpd
 public class SetParentIT extends AbstractDaemonTest {
-
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -42,8 +43,7 @@
   public void setParentNotAllowed() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).parent(parent);
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
   }
 
   @Test
@@ -51,8 +51,7 @@
   public void setParentNotAllowedForNonOwners() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).parent(parent);
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
   }
 
   @Test
@@ -75,7 +74,11 @@
   public void setParentAllowedForOwners() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    grant(project, "refs/*", Permission.OWNER, false, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
   }
@@ -96,47 +99,63 @@
 
   @Test
   public void setParentForAllProjectsNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
-    gApi.projects().name(allProjects.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).parent(project.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
   }
 
   @Test
   public void setParentToSelfNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent to self");
-    gApi.projects().name(project.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(project.get()));
+    assertThat(thrown).hasMessageThat().contains("cannot set parent to self");
   }
 
   @Test
   public void setParentToOwnChildNotAllowed() throws Exception {
     String child = projectOperations.newProject().parent(project).create().get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(child);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(child));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToGrandchildNotAllowed() throws Exception {
     Project.NameKey child = projectOperations.newProject().parent(project).create();
     String grandchild = projectOperations.newProject().parent(child).create().get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(grandchild);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(grandchild));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToNonexistentProject() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    gApi.projects().name(project.get()).parent("non-existing");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).parent("non-existing"));
+    assertThat(thrown).hasMessageThat().contains("not found");
   }
 
   @Test
   public void setParentToAllUsersNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().name(project.get()).parent(allUsers.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(allUsers.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
@@ -145,8 +164,9 @@
 
     String parent = projectOperations.newProject().create().get();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("All-Users must inherit from All-Projects");
-    gApi.projects().name(allUsers.get()).parent(parent);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
+    assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index c27d637..d4a4c45 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -16,10 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
 
@@ -2752,7 +2753,7 @@
 
       RevCommit parentCommit = c.getParents()[0];
       String parentCommitId =
-          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+          abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
       SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2375607..32941ff 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -21,10 +21,12 @@
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -38,12 +40,20 @@
 import com.google.common.collect.Iterators;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -72,8 +82,6 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -83,11 +91,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetRevisionActions;
@@ -115,11 +118,10 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
   @Inject private GetRevisionActions getRevisionActions;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -182,14 +184,13 @@
     assertThat(approval.postSubmit).isNull();
 
     // Reducing vote is not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
@@ -202,14 +203,13 @@
     assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
+    thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
@@ -261,9 +261,11 @@
     ReviewInput in = new ReviewInput();
     in.label("Code-Review", 0);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot reduce vote on labels for closed change: Code-Review");
-    revision(r).review(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot reduce vote on labels for closed change: Code-Review");
   }
 
   @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
@@ -279,28 +281,36 @@
     PatchSetApproval psa =
         Iterators.getOnlyElement(
             cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo(2);
-    assertThat(psa.isPostSubmit()).isFalse();
+    assertThat(psa.patchSetId().get()).isEqualTo(2);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo(2);
+    assertThat(psa.postSubmit()).isFalse();
   }
 
   @Test
   public void voteOnAbandonedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is closed");
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject()));
+    assertThat(thrown).hasMessageThat().contains("change is closed");
   }
 
   @Test
   public void voteNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("is restricted");
-    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChange().getId().get())
+                    .current()
+                    .review(ReviewInput.approve()));
+    assertThat(thrown).hasMessageThat().contains("is restricted");
   }
 
   @Test
@@ -331,6 +341,34 @@
   }
 
   @Test
+  public void cherryPickWithoutMessage() throws Exception {
+    String branch = "foo";
+
+    // Create change to cherry-pick
+    PushOneCommit.Result change = createChange();
+    RevCommit revCommit = change.getCommit();
+
+    // Create target branch to cherry-pick to.
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    // Cherry-pick without message.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = branch;
+    String changeId =
+        gApi.changes()
+            .id(change.getChangeId())
+            .revision(revCommit.name())
+            .cherryPickAsInfo(input)
+            .id;
+
+    // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(revCommit.getFullMessage());
+  }
+
+  @Test
   public void cherryPickSetChangeId() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
@@ -456,9 +494,11 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: identical tree");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: identical tree");
   }
 
   @Test
@@ -482,9 +522,11 @@
     ChangeApi orig = gApi.changes().id(triplet);
     assertThat(orig.get().messages).hasSize(1);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: merge conflict");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: merge conflict");
   }
 
   @Test
@@ -522,12 +564,11 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = destBranch;
     in.message = "Cherry-Pick";
-    try {
-      changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage()).isEqualTo("Cherry pick failed: merge conflict");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("Cherry pick failed: merge conflict");
 
     // Cherry-pick with auto merge should succeed.
     in.allowConflicts = true;
@@ -551,8 +592,8 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = getRemoteHead(project, destBranch).abbreviate(6).name();
-    String changeSha1 = r.getCommit().abbreviate(6).name();
+    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
+    String changeSha1 = abbreviateName(r.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< HEAD   ("
@@ -604,16 +645,15 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = r1.getCommit().getFullMessage();
-    try {
-      gApi.changes().id(t1).current().cherryPick(in);
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Cannot create new patch set of change "
-                  + info(t2)._number
-                  + " because it is abandoned");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Cannot create new patch set of change "
+                + info(t2)._number
+                + " because it is abandoned");
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
@@ -629,7 +669,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -656,7 +696,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -684,17 +724,24 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
     cherryPickInput.message = "Cherry-pick a merge commit to another branch";
     cherryPickInput.parent = 0;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
@@ -705,24 +752,31 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
     cherryPickInput.message = "Cherry-pick a merge commit to another branch";
     cherryPickInput.parent = 3;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
   public void cherryPickNotify() throws Exception {
-    createBranch(new Branch.NameKey(project, "branch-1"));
-    createBranch(new Branch.NameKey(project, "branch-2"));
-    createBranch(new Branch.NameKey(project, "branch-3"));
+    createBranch(BranchNameKey.create(project, "branch-1"));
+    createBranch(BranchNameKey.create(project, "branch-2"));
+    createBranch(BranchNameKey.create(project, "branch-3"));
 
     // Creates a change for 'admin'.
     PushOneCommit.Result result = createChange();
@@ -761,7 +815,7 @@
 
   @Test
   public void cherryPickKeepReviewers() throws Exception {
-    createBranch(new Branch.NameKey(project, "stable"));
+    createBranch(BranchNameKey.create(project, "stable"));
 
     // Change is created by 'admin'.
     PushOneCommit.Result r = createChange();
@@ -795,7 +849,7 @@
 
   @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -819,7 +873,7 @@
 
   @Test
   public void cherryPickToOpenChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -837,7 +891,7 @@
 
   @Test
   public void cherryPickToNonVisibleChangeFails() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -852,10 +906,13 @@
     input.message = srcChange.getCommit().getFullMessage();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(
-        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
-    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
   }
 
   @Test
@@ -869,12 +926,16 @@
     input.base = change2.getCommit().name();
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "Change %s with commit %s is abandoned",
-            change2.getChange().getId().get(), input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "Change %s with commit %s is abandoned",
+                change2.getChange().getId().get(), input.base));
   }
 
   @Test
@@ -886,9 +947,13 @@
     input.base = "invalid-sha1";
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Base %s doesn't represent a valid SHA-1", input.base));
   }
 
   @Test
@@ -929,7 +994,7 @@
 
   @Test
   public void cherryPickToNonExistingBaseCommit() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
     PushOneCommit.Result result = createChange();
 
     CherryPickInput input = new CherryPickInput();
@@ -1050,25 +1115,22 @@
 
     // Using the API returns the correct value, and reindexes as well.
     CountDownLatch reindexed = new CountDownLatch(1);
-    RegistrationHandle handle =
-        changeIndexedListeners.add(
-            "gerrit",
-            new ChangeIndexedListener() {
-              @Override
-              public void onChangeIndexed(String projectName, int id) {
-                if (id == id2.get()) {
-                  reindexed.countDown();
-                }
-              }
+    ChangeIndexedListener listener =
+        new ChangeIndexedListener() {
+          @Override
+          public void onChangeIndexed(String projectName, int id) {
+            if (id == id2.get()) {
+              reindexed.countDown();
+            }
+          }
 
-              @Override
-              public void onChangeDeleted(int id) {}
-            });
-    try {
+          @Override
+          public void onChangeDeleted(int id) {}
+        };
+
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
       assertMergeable(r2.getChangeId(), true);
       reindexed.await();
-    } finally {
-      handle.remove();
     }
 
     List<ChangeInfo> changes = search.call();
@@ -1228,16 +1290,26 @@
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit description not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().name())
+                    .description("test"));
+    assertThat(thrown).hasMessageThat().contains("edit description not permitted");
   }
 
   @Test
   public void setDescriptionAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
-    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
     assertDescription(r, "test");
@@ -1276,10 +1348,14 @@
   @Test
   public void commit() throws Exception {
     WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    RegistrationHandle handle =
-        patchSetLinks.add("gerrit", (projectName, commit) -> expectedWebLinkInfo);
-
-    try {
+    PatchSetWebLink link =
+        new PatchSetWebLink() {
+          @Override
+          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+            return expectedWebLinkInfo;
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       PushOneCommit.Result r = createChange();
       RevCommit c = r.getCommit();
 
@@ -1301,8 +1377,6 @@
       assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
       assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
       assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
-    } finally {
-      handle.remove();
     }
   }
 
@@ -1413,8 +1487,8 @@
 
   @Test
   public void commentOnNonExistingFile() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r = updateChange(r, "new content");
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = updateChange(r1, "new content");
     CommentInput in = new CommentInput();
     in.line = 1;
     in.message = "nit: trailing whitespace";
@@ -1425,10 +1499,14 @@
     reviewInput.comments = comments;
     reviewInput.message = "comment test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format("not found in revision %d,1", r.getChange().change().getId().id));
-    gApi.changes().id(r.getChangeId()).revision(1).review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(1).review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("not found in revision %d,1", r2.getChange().change().getId().get()));
   }
 
   @Test
@@ -1456,9 +1534,11 @@
     String res = new String(os.toByteArray(), UTF_8);
     assertThat(res).isEqualTo(PATCH_FILE_ONLY);
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("File not found: nonexistent-file.");
-    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> changeApi.revision(r.getCommit().name()).patch("nonexistent-file"));
+    assertThat(thrown).hasMessageThat().contains("File not found: nonexistent-file.");
   }
 
   @Test
@@ -1506,13 +1586,16 @@
 
     // check if it's blocked to delete a vote on a non-current patch set.
     requestScopeOperations.setApiUser(admin.id());
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot access on non-current patch set");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().getName())
-        .reviewer(user.id().toString())
-        .deleteVote("Code-Review");
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().getName())
+                    .reviewer(user.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
   @Test
@@ -1622,9 +1705,9 @@
     RevCommit initialCommit = getHead(repo(), "HEAD");
 
     String branchAName = "branchA";
-    createBranch(new Branch.NameKey(project, branchAName));
+    createBranch(BranchNameKey.create(project, branchAName));
     String branchBName = "branchB";
-    createBranch(new Branch.NameKey(project, branchBName));
+    createBranch(BranchNameKey.create(project, branchBName));
 
     PushOneCommit.Result changeAResult =
         pushFactory
@@ -1659,6 +1742,6 @@
   }
 
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index ba228f6..62a7037 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -164,9 +165,10 @@
     int sizeOfRest = 451;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
@@ -187,9 +189,10 @@
     int sizeLimit = 10 * 1024;
     fixReplacementInfo.replacement = getStringFor(sizeLimit);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
@@ -254,12 +257,15 @@
   public void descriptionOfFixSuggestionIsMandatory() throws Exception {
     fixSuggestionInfo.description = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A description is required for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A description is required for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -278,13 +284,16 @@
   public void fixReplacementsAreMandatory() throws Exception {
     fixSuggestionInfo.replacements = Collections.emptyList();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "At least one replacement is required"
-                + " for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "At least one replacement is required"
+                    + " for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -305,12 +314,15 @@
   public void pathOfFixReplacementIsMandatory() throws Exception {
     fixReplacementInfo.path = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A file path must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A file path must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -331,20 +343,24 @@
   public void rangeOfFixReplacementIsMandatory() throws Exception {
     fixReplacementInfo.range = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A range must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A range must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Range (13:9 - 5:10)");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
   }
 
   @Test
@@ -364,9 +380,10 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("overlap");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("overlap");
   }
 
   @Test
@@ -461,13 +478,16 @@
   public void replacementStringOfFixReplacementIsMandatory() throws Exception {
     fixReplacementInfo.replacement = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A content for replacement must be "
-                + "indicated for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A content for replacement must be "
+                    + "indicated for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -602,9 +622,11 @@
 
     List<String> fixIds = getFixIds(robotCommentInfos);
     gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("merge");
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+    assertThat(thrown).hasMessageThat().contains("merge");
   }
 
   @Test
@@ -708,8 +730,9 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixId));
   }
 
   @Test
@@ -728,9 +751,11 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("current");
-    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("current");
   }
 
   @Test
@@ -783,9 +808,11 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("based");
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("based");
   }
 
   @Test
@@ -845,8 +872,9 @@
     String fixId = Iterables.getOnlyElement(fixIds);
     String nonExistentFixId = fixId + "_non-existent";
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(nonExistentFixId));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 3a7b6c2..f0448aa 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -16,17 +16,17 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -36,19 +36,24 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -56,14 +61,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Put;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -79,11 +81,10 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.AfterClass;
 import org.junit.Before;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
+@UseClockStep
 public class ChangeEditIT extends AbstractDaemonTest {
 
   private static final String FILE_NAME = "foo";
@@ -101,16 +102,6 @@
   private String changeId2;
   private PatchSet ps;
 
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
   @Before
   public void setUp() throws Exception {
     changeId = newChange(admin.newIdent());
@@ -195,7 +186,7 @@
     adminRestSession.post(urlPublish(changeId)).assertNoContent();
     assertThat(getEdit(changeId)).isAbsent();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
+    assertThat(newCurrentPatchSet.id()).isNotEqualTo(oldCurrentPatchSet.id());
     assertChangeMessages(
         changeId,
         ImmutableList.of(
@@ -248,13 +239,13 @@
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
     gApi.changes().id(changeId2).edit().rebase();
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
     Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -267,13 +258,13 @@
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
     adminRestSession.post(urlRebase(changeId2)).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
     Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -283,7 +274,7 @@
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
     Optional<EditInfo> edit = getEdit(changeId2);
-    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -327,9 +318,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -337,9 +332,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -390,7 +389,7 @@
     r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.commitId().name()));
       assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
     }
 
@@ -594,16 +593,22 @@
   @Test
   public void writeNoChanges() throws Exception {
     createEmptyEditFor(changeId);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD)));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
     String cr = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyAllScoresIfNoCodeChange(true);
       u.getConfig().getLabelSections().put(cr, codeReview);
       u.save();
@@ -696,7 +701,11 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as user
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
@@ -704,8 +713,7 @@
     r1.assertOkStatus();
 
     // Try to create edit as admin
-    exception.expect(AuthException.class);
-    createEmptyEditFor(r1.getChangeId());
+    assertThrows(AuthException.class, () -> createEmptyEditFor(r1.getChangeId()));
   }
 
   @Test
@@ -714,9 +722,11 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     gApi.changes().id(changeId).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is merged", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is merged", change._number));
   }
 
   @Test
@@ -724,9 +734,32 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is abandoned", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is abandoned", change._number));
+  }
+
+  @Test
+  public void sha1sOfTwoChangesWithSameContentAfterEditDiffer() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "Empty change";
+    changeInput.status = ChangeStatus.NEW;
+
+    ChangeInfo info1 = gApi.changes().create(changeInput).get();
+    gApi.changes().id(info1._number).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(info1._number).edit().publish(new PublishChangeEditInput());
+    info1 = gApi.changes().id(info1._number).get();
+
+    ChangeInfo info2 = gApi.changes().create(changeInput).get();
+    gApi.changes().id(info2._number).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(info2._number).edit().publish(new PublishChangeEditInput());
+    info2 = gApi.changes().id(info2._number).get();
+
+    assertThat(info1.currentRevision).isNotEqualTo(info2.currentRevision);
   }
 
   private void createArbitraryEditFor(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 8ebb2bd..3b80312 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -16,14 +16,17 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.PushResult;
@@ -31,6 +34,7 @@
 import org.junit.Test;
 
 public abstract class AbstractForcePush extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void forcePushNotAllowed() throws Exception {
@@ -55,7 +59,11 @@
   @Test
   public void forcePushAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
         pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -80,19 +88,31 @@
 
   @Test
   public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, false);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()))
+        .update();
     assertDeleteRef(REJECTED_OTHER_REASON);
   }
 
   @Test
   public void deleteAllowedWithForcePushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
   @Test
   public void deleteAllowedWithDeletePermission() throws Exception {
-    grant(project, "refs/*", Permission.DELETE, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 216677d..cab12b3 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -17,12 +17,17 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.GitUtil.pushOne;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
@@ -33,10 +38,9 @@
 import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.util.Comparator.comparing;
-import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
@@ -46,17 +50,27 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.SkipProjectClone;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -77,30 +91,18 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.testing.EditInfoSubject;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.CommitValidators.ChangeIdValidator;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -117,6 +119,7 @@
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -128,52 +131,51 @@
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.After;
-import org.junit.AfterClass;
 import org.junit.Before;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
 @SkipProjectClone
+@UseClockStep
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
   protected enum Protocol {
-    // TODO(dborowitz): TEST.
+    // Only test protocols which are actually served by the Gerrit server, since each separate test
+    // class is large and slow.
+    //
+    // This list excludes the test InProcessProtocol, which is used by large numbers of other
+    // acceptance tests. Small tests of InProcessProtocol are still possible, without incurring a
+    // new large slow test.
     SSH,
     HTTP
   }
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private static String NEW_CHANGE_INDICATOR = " [NEW]";
   private LabelType patchSetLock;
 
-  @Inject private DynamicSet<CommitValidationListener> commitValidators;
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
   @Before
   public void setUpPatchSetLock() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      patchSetLock = Util.patchSetLock();
+      patchSetLock = TestLabels.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(0, 1))
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(adminGroupUuid())
+                .range(0, 1))
+        .update();
   }
 
   @After
@@ -859,7 +861,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     // Admin user trying to move from WIP to ready should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user, testRepo);
     r.assertOkStatus();
@@ -875,7 +877,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     // Admin user trying to move from ready to WIP should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
     r.assertOkStatus();
@@ -886,16 +888,26 @@
 
     // Non owner, non admin and non project owner cannot flip wip bit:
     TestAccount user2 = accountCreator.user2();
-    grant(
-        project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.FORGE_COMMITTER)
+                .ref("refs/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     TestRepository<?> user2Repo = cloneProject(project, user2);
-    GitUtil.fetch(user2Repo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(user2Repo, r.getPatchSet().refName() + ":ps");
     user2Repo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
 
     // Project owner trying to move from WIP to ready should succeed.
-    allow("refs/*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertOkStatus();
   }
@@ -1196,14 +1208,17 @@
   @Test
   public void pushWithMultipleApprovals() throws Exception {
     LabelType Q =
-        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+        label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
       u.getConfig().getLabelSections().put(Q.getName(), Q);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Custom-Label").ref(heads).group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     RevCommit c =
         commitBuilder()
@@ -1225,40 +1240,6 @@
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void pushToRefsChangesAllowed() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertOkStatus();
-  }
-
-  @Test
-  public void pushNewPatchsetToRefsChanges() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertErrorStatus("upload to refs/changes not allowed");
-  }
-
-  @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "false")
-  public void pushToRefsChangesNotAllowed() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertErrorStatus("upload to refs/changes not allowed");
-  }
-
-  private PushOneCommit.Result pushOneCommitToRefsChanges() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    return push.to("refs/changes/" + r.getChange().change().getId().get());
-  }
-
-  @Test
   public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -1364,7 +1345,11 @@
     r.assertOkStatus();
 
     setUseSignedOffBy(InheritableBoolean.TRUE);
-    block(project, "refs/heads/master", Permission.FORGE_COMMITTER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     push =
         pushFactory.create(
@@ -1420,7 +1405,7 @@
 
     // create a second change as user (depends on the change from admin)
     TestRepository<?> userRepo = cloneProject(project, user);
-    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
+    GitUtil.fetch(userRepo, r.getPatchSet().refName() + ":change");
     userRepo.reset("change");
     push =
         pushFactory.create(
@@ -1434,7 +1419,11 @@
 
   @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
@@ -1530,8 +1519,8 @@
 
     // Check that a change was created for each.
     for (RevCommit c : commits) {
-      assertThat(byCommit(c).change().getSubject())
-          .named("change for " + c.name())
+      assertWithMessage("change for " + c.name())
+          .that(byCommit(c).change().getSubject())
           .isEqualTo(c.getShortMessage());
     }
 
@@ -1543,9 +1532,9 @@
       RevCommit c2 = commits2.get(i);
       String name = "change for " + c2.name();
       ChangeData cd = byCommit(c);
-      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
-      assertThat(getPatchSetRevisions(cd))
-          .named(name)
+      assertWithMessage(name).that(cd.change().getSubject()).isEqualTo(c2.getShortMessage());
+      assertWithMessage(name)
+          .that(getPatchSetRevisions(cd))
           .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
     }
 
@@ -1608,37 +1597,13 @@
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
     assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     String reason =
-        String.format(
-            "commit %s: missing Change-Id in message footer", c.toObjectId().abbreviate(7).name());
+        String.format("commit %s: missing Change-Id in message footer", abbreviateName(c));
     assertThat(refUpdate.getMessage()).isEqualTo(reason);
 
     assertThat(r.getMessages()).contains("\nERROR: " + reason);
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void testPushWithChangedChangeId() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT
-                + "\n\n"
-                + "Change-Id: I55eab7c7a76e95005fa9cc469aa8f9fc16da9eba\n",
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/changes/" + r.getChange().change().getId().get());
-    r.assertErrorStatus(
-        String.format(
-            "commit %s: %s",
-            r.getCommit().abbreviate(RevId.ABBREV_LEN).name(),
-            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG));
-  }
-
-  @Test
   public void pushWithMultipleChangeIds() throws Exception {
     testPushWithMultipleChangeIds();
   }
@@ -1814,30 +1779,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void accidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
-      throws Exception {
-    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
-    ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
-
-    String r = "refs/changes/" + id;
-    assertPushOk(pushHead(testRepo, r, false), r);
-
-    // Added a new patch set and auto-closed the change.
-    cd = byChangeId(id);
-    assertThat(cd.change().isMerged()).isTrue();
-    assertThat(getPatchSetRevisions(cd))
-        .containsExactlyEntriesIn(
-            ImmutableMap.of(1, ps1Rev, 2, testRepo.getRepository().resolve("HEAD").name()));
-  }
-
-  @Test
   public void accidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
       throws Exception {
     Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
     ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).commitId().name();
 
     String r = "refs/for/master";
     assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
@@ -1850,7 +1796,11 @@
 
   @Test
   public void forcePushAbandonedChange() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
         pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r = push1.to("refs/for/master");
@@ -1923,7 +1873,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyMaxScore(true);
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
       u.save();
@@ -1946,7 +1896,11 @@
   @Test
   public void createChangeForMergedCommit() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     // Update master with a direct push.
     RevCommit c1 = testRepo.commit().message("Non-change 1").create();
@@ -2045,7 +1999,11 @@
   @Test
   public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -2238,53 +2196,6 @@
     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);
-      r.assertErrorStatus();
-      r.assertMessage("draft workflow is disabled");
-    }
-  }
-
-  @GerritConfig(name = "change.allowDrafts", value = "true")
-  @Test
-  public void pushDraftGetsPrivateChange() throws Exception {
-    String changeId1 = createChange("refs/drafts/master").getChangeId();
-    String changeId2 = createChange("refs/for/master%draft").getChangeId();
-
-    ChangeInfo info1 = gApi.changes().id(changeId1).get();
-    ChangeInfo info2 = gApi.changes().id(changeId2).get();
-
-    assertThat(info1.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info2.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info1.isPrivate).isTrue();
-    assertThat(info2.isPrivate).isTrue();
-    assertThat(info1.revisions).hasSize(1);
-    assertThat(info2.revisions).hasSize(1);
-  }
-
-  @GerritConfig(name = "change.allowDrafts", value = "true")
-  @Sandboxed
-  @Test
-  public void pushWithDraftOptionToExistingNewChangeGetsChangeEdit() throws Exception {
-    String changeId = createChange().getChangeId();
-    EditInfoSubject.assertThat(getEdit(changeId)).isAbsent();
-
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    ChangeStatus originalChangeStatus = changeInfo.status;
-
-    PushOneCommit.Result result = amendChange(changeId, "refs/drafts/master");
-    result.assertOkStatus();
-
-    changeInfo = gApi.changes().id(changeId).get();
-    assertThat(changeInfo.status).isEqualTo(originalChangeStatus);
-    assertThat(changeInfo.isPrivate).isNull();
-    assertThat(changeInfo.revisions).hasSize(1);
-
-    EditInfoSubject.assertThat(getEdit(changeId)).isPresent();
-  }
-
   @GerritConfig(name = "receive.maxBatchCommits", value = "2")
   @Test
   public void maxBatchCommits() throws Exception {
@@ -2294,24 +2205,16 @@
   @GerritConfig(name = "receive.maxBatchCommits", value = "2")
   @Test
   public void maxBatchCommitsWithDefaultValidator() throws Exception {
-    TestValidator validator = new TestValidator();
-    RegistrationHandle handle = commitValidators.add("test-validator", validator);
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(new TestValidator())) {
       testMaxBatchCommits();
-    } finally {
-      handle.remove();
     }
   }
 
   @GerritConfig(name = "receive.maxBatchCommits", value = "2")
   @Test
   public void maxBatchCommitsWithValidateAllCommitsValidator() throws Exception {
-    TestValidator validator = new TestValidator(true);
-    RegistrationHandle handle = commitValidators.add("test-validator", validator);
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(new TestValidator())) {
       testMaxBatchCommits();
-    } finally {
-      handle.remove();
     }
   }
 
@@ -2369,10 +2272,7 @@
   public void skipValidation() throws Exception {
     String master = "refs/heads/master";
     TestValidator validator = new TestValidator();
-    RegistrationHandle handle = commitValidators.add("test-validator", validator);
-    RegistrationHandle handle2 = null;
-
-    try {
+    try (Registration registration = extensionRegistry.newRegistration().add(validator)) {
       // Validation listener is called on normal push
       PushOneCommit push =
           pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
@@ -2401,20 +2301,16 @@
       // Validation listener that needs to validate all commits gets called even
       // when the skip option is used.
       TestValidator validator2 = new TestValidator(true);
-      handle2 = commitValidators.add("test-validator-2", validator2);
-      PushOneCommit push4 =
-          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
-      push4.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
-      r = push4.to(master);
-      r.assertOkStatus();
-      // First listener was not called; its count remains the same.
-      assertThat(validator.count()).isEqualTo(1);
-      // Second listener was called.
-      assertThat(validator2.count()).isEqualTo(1);
-    } finally {
-      handle.remove();
-      if (handle2 != null) {
-        handle2.remove();
+      try (Registration registration2 = extensionRegistry.newRegistration().add(validator2)) {
+        PushOneCommit push4 =
+            pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+        push4.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+        r = push4.to(master);
+        r.assertOkStatus();
+        // First listener was not called; its count remains the same.
+        assertThat(validator.count()).isEqualTo(1);
+        // Second listener was called.
+        assertThat(validator2.count()).isEqualTo(1);
       }
     }
   }
@@ -2435,12 +2331,19 @@
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "NoteDb update requires access database permission");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "prohibited by Gerrit: not permitted: create");
 
-    grant(project, "refs/changes/*", Permission.CREATE);
-    grant(project, "refs/changes/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/changes/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/changes/*").group(adminGroupUuid()))
+        .update();
     grantSkipValidation(project, "refs/changes/*", REGISTERED_USERS);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushOk(pr, ref);
@@ -2489,9 +2392,9 @@
     assertThat(pr.getMessages())
         .contains(
             "warning: no changes between prior commit "
-                + c.abbreviate(7).name()
+                + abbreviateName(c)
                 + " and new commit "
-                + amended.abbreviate(7).name());
+                + abbreviateName(amended));
   }
 
   @Test
@@ -2517,8 +2420,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains(
-            "warning: " + amended.abbreviate(7).name() + ": no files changed, message updated");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, message updated");
   }
 
   @Test
@@ -2542,8 +2444,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains(
-            "warning: " + amended.abbreviate(7).name() + ": no files changed, author changed");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, author changed");
   }
 
   @Test
@@ -2570,7 +2471,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains("warning: " + amended.abbreviate(7).name() + ": no files changed, was rebased");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, was rebased");
   }
 
   @Test
@@ -2641,6 +2542,76 @@
             + "\n");
   }
 
+  @Test
+  public void cannotPushTheSameCommitTwiceForReviewToTheSameBranch() throws Exception {
+    testCannotPushTheSameCommitTwiceForReviewToTheSameBranch();
+  }
+
+  @Test
+  public void cannotPushTheSameCommitTwiceForReviewToTheSameBranchCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testCannotPushTheSameCommitTwiceForReviewToTheSameBranch();
+  }
+
+  private void testCannotPushTheSameCommitTwiceForReviewToTheSameBranch() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+
+    // create a commit without Change-Id
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .author(user.newIdent())
+        .committer(user.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+        .message(PushOneCommit.SUBJECT)
+        .create();
+
+    // push the commit for review to create a change
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    assertPushOk(r, "refs/for/master");
+
+    // try to push the same commit for review again to create another change on the same branch,
+    // it's expected that this is rejected with "no new changes"
+    r = pushHead(testRepo, "refs/for/master");
+    assertPushRejected(r, "refs/for/master", "no new changes");
+  }
+
+  @Test
+  public void pushTheSameCommitTwiceForReviewToDifferentBranches() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+
+    // create a commit without Change-Id
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .author(user.newIdent())
+        .committer(user.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+        .message(PushOneCommit.SUBJECT)
+        .create();
+
+    // push the commit for review to create a change
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    assertPushOk(r, "refs/for/master");
+
+    // create another branch
+    gApi.projects().name(project.get()).branch("otherBranch").create(new BranchInput());
+
+    // try to push the same commit for review again to create a change on another branch,
+    // it's expected that this is rejected with "no new changes" since
+    // CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET is false
+    r = pushHead(testRepo, "refs/for/otherBranch");
+    assertPushRejected(r, "refs/for/otherBranch", "no new changes");
+
+    enableCreateNewChangeForAllNotInTarget();
+
+    // try to push the same commit for review again to create a change on another branch,
+    // now it should succeed since CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET is true
+    r = pushHead(testRepo, "refs/for/otherBranch");
+    assertPushOk(r, "refs/for/otherBranch");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
@@ -2685,11 +2656,11 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.id());
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
         // Remove reviewer from PS1 so we can test adding this same reviewer on PS2 below.
         gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.id().toString()).remove();
       }
-      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+      assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
     }
 
     List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
@@ -2698,9 +2669,9 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.id());
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
       } else {
-        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+        assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
       }
     }
   }
@@ -2767,20 +2738,20 @@
   private static Map<Integer, String> getPatchSetRevisions(ChangeData cd) throws Exception {
     Map<Integer, String> revisions = new HashMap<>();
     for (PatchSet ps : cd.patchSets()) {
-      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
+      revisions.put(ps.number(), ps.commitId().name());
     }
     return revisions;
   }
 
   private ChangeData byCommit(ObjectId id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byCommit(id);
-    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    assertWithMessage("change for " + id.name()).that(cds).hasSize(1);
     return cds.get(0);
   }
 
   private ChangeData byChangeId(Change.Id id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
-    assertThat(cds).named("change " + id).hasSize(1);
+    assertWithMessage("change " + id).that(cds).hasSize(1);
     return cds.get(0);
   }
 
@@ -2808,13 +2779,14 @@
   private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
       throws Exception {
     // See SKIP_VALIDATION implementation in default permission backend.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.FORGE_AUTHOR, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_COMMITTER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_SERVER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_COMMITTER).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref(ref).group(groupUuid))
+        .add(allow(Permission.PUSH_MERGE).ref("refs/for/" + ref).group(groupUuid))
+        .update();
   }
 
   private PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
@@ -2833,4 +2805,8 @@
         ? infos.stream().map(a -> a.email).collect(toImmutableList())
         : ImmutableList.of();
   }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index c02a5ed..7cfb0f2 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -17,21 +17,30 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -48,6 +57,7 @@
 
 public abstract class AbstractSubmitOnPush extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
 
   @Before
   public void blockAnonymous() throws Exception {
@@ -56,7 +66,11 @@
 
   @Test
   public void submitOnPush() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
@@ -66,7 +80,11 @@
 
   @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
-    grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/meta/config").group(adminGroupUuid()))
+        .update();
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
@@ -84,7 +102,11 @@
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
@@ -100,7 +122,11 @@
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "b.txt", "other content");
     r.assertOkStatus();
@@ -113,7 +139,11 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     r =
         push(
             "refs/for/master%submit",
@@ -153,7 +183,11 @@
 
   @Test
   public void mergeOnPushToBranch() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
@@ -162,28 +196,32 @@
     assertCommit(project, "refs/heads/master");
 
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
     RevCommit c = r.getCommit();
-    PatchSet.Id psId = cd.currentPatchSet().getId();
+    PatchSet.Id psId = cd.currentPatchSet().id();
     assertThat(psId.get()).isEqualTo(1);
     assertThat(cd.change().isMerged()).isTrue();
     assertSubmitApproval(psId);
 
     assertThat(cd.patchSets()).hasSize(1);
-    assertThat(cd.patchSet(psId).getRevision().get()).isEqualTo(c.name());
+    assertThat(cd.patchSet(psId).commitId()).isEqualTo(c);
   }
 
   @Test
   public void correctNewRevOnMergeByPushToBranch() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     push("refs/for/master", PushOneCommit.SUBJECT, "one.txt", "One");
     PushOneCommit.Result r = push("refs/for/master", PushOneCommit.SUBJECT, "two.txt", "Two");
     startEventRecorder();
     git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
     List<ChangeMergedEvent> changeMergedEvents =
         eventRecorder.getChangeMergedEvents(project.get(), "refs/heads/master", 2);
-    assertThat(changeMergedEvents.get(0).newRev).isEqualTo(r.getPatchSet().getRevision().get());
-    assertThat(changeMergedEvents.get(1).newRev).isEqualTo(r.getPatchSet().getRevision().get());
+    assertThat(changeMergedEvents.get(0).newRev).isEqualTo(r.getPatchSet().commitId().name());
+    assertThat(changeMergedEvents.get(1).newRev).isEqualTo(r.getPatchSet().commitId().name());
   }
 
   @Test
@@ -191,10 +229,14 @@
     enableCreateNewChangeForAllNotInTarget();
     String master = "refs/heads/master";
     String other = "refs/heads/other";
-    grant(project, master, Permission.PUSH);
-    grant(project, other, Permission.CREATE);
-    grant(project, other, Permission.PUSH);
-    RevCommit masterRev = getRemoteHead();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()))
+        .add(allow(Permission.CREATE).ref(other).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(other).group(adminGroupUuid()))
+        .update();
+    RevCommit masterRev = projectOperations.project(project).getHead("master");
     pushCommitTo(masterRev, other);
     PushOneCommit.Result r = createChange();
     r.assertOkStatus();
@@ -202,7 +244,7 @@
     pushCommitTo(commit, master);
     assertCommit(project, master);
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
     assertThat(cd.change().isMerged()).isTrue();
 
     RemoteRefUpdate.Status status = pushCommitTo(commit, "refs/for/other");
@@ -211,8 +253,8 @@
     pushCommitTo(commit, other);
     assertCommit(project, other);
 
-    for (ChangeData c : queryProvider.get().byKey(new Change.Key(r.getChangeId()))) {
-      if (c.change().getDest().get().equals(other)) {
+    for (ChangeData c : queryProvider.get().byKey(Change.key(r.getChangeId()))) {
+      if (c.change().getDest().branch().equals(other)) {
         assertThat(c.change().isMerged()).isTrue();
       }
     }
@@ -228,7 +270,11 @@
 
   @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -256,13 +302,17 @@
     assertSubmitApproval(psId2);
 
     assertThat(cd.patchSets()).hasSize(2);
-    assertThat(cd.patchSet(psId1).getRevision().get()).isEqualTo(c1.name());
-    assertThat(cd.patchSet(psId2).getRevision().get()).isEqualTo(c2.name());
+    assertThat(cd.patchSet(psId1).commitId()).isEqualTo(c1);
+    assertThat(cd.patchSet(psId2).commitId()).isEqualTo(c2);
   }
 
   @Test
   public void mergeOnPushToBranchWithOldPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -273,26 +323,30 @@
     r = amendChange(changeId);
     ChangeData cd = r.getChange();
     PatchSet.Id psId2 = cd.change().currentPatchSetId();
-    assertThat(psId2.getParentKey()).isEqualTo(psId1.getParentKey());
+    assertThat(psId2.changeId()).isEqualTo(psId1.changeId());
     assertThat(psId2.get()).isEqualTo(2);
 
     testRepo.reset(c1);
     assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
 
-    cd = changeDataFactory.create(project, psId1.getParentKey());
+    cd = changeDataFactory.create(project, psId1.changeId());
     Change c = cd.change();
     assertThat(c.isMerged()).isTrue();
     assertThat(c.currentPatchSetId()).isEqualTo(psId1);
-    assertThat(cd.patchSets().stream().map(PatchSet::getId).collect(toList()))
+    assertThat(cd.patchSets().stream().map(PatchSet::id).collect(toList()))
         .containsExactly(psId1, psId2);
   }
 
   @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
 
     // Create 2 changes.
-    ObjectId initialHead = getRemoteHead();
+    ObjectId initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result r1 = createChange("Change 1", "a", "a");
     r1.assertOkStatus();
     PushOneCommit.Result r2 = createChange("Change 2", "b", "b");
@@ -323,27 +377,96 @@
     assertThat(cd2.change().isMerged()).isTrue();
     PatchSet.Id psId2_2 = cd2.change().currentPatchSetId();
     assertThat(psId2_2.get()).isEqualTo(2);
-    assertThat(cd2.patchSet(psId2_1).getRevision().get()).isEqualTo(c2_1.name());
-    assertThat(cd2.patchSet(psId2_2).getRevision().get()).isEqualTo(c2_2.name());
+    assertThat(cd2.patchSet(psId2_1).commitId()).isEqualTo(c2_1);
+    assertThat(cd2.patchSet(psId2_2).commitId()).isEqualTo(c2_2);
 
     ChangeData cd1 = r1.getChange();
     assertThat(cd1.change().isMerged()).isTrue();
     PatchSet.Id psId1_2 = cd1.change().currentPatchSetId();
     assertThat(psId1_2.get()).isEqualTo(2);
-    assertThat(cd1.patchSet(psId1_1).getRevision().get()).isEqualTo(c1_1.name());
-    assertThat(cd1.patchSet(psId1_2).getRevision().get()).isEqualTo(c1_2.name());
+    assertThat(cd1.patchSet(psId1_1).commitId()).isEqualTo(c1_1);
+    assertThat(cd1.patchSet(psId1_2).commitId()).isEqualTo(c1_2);
+  }
+
+  @Test
+  public void pushForSubmitWithNotifyOption() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    TestAccount user = accountCreator.user();
+    String pushSpec = "refs/for/master%reviewer=" + user.email();
+    sender.clear();
+
+    PushOneCommit.Result result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE);
+    result.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER);
+    result.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER_REVIEWERS);
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.ALL);
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit"); // default is notify = ALL
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+  }
+
+  @Test
+  public void pushForSubmitWithNotifyingUsersExplicitly() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    TestAccount user = accountCreator.user();
+    String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user.email();
+
+    TestAccount user2 = accountCreator.user2();
+
+    sender.clear();
+    PushOneCommit.Result result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-to=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.TO);
+
+    sender.clear();
+    result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-cc=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.CC);
+
+    sender.clear();
+    result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-bcc=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.BCC);
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
-    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.getParentKey()).load();
+    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.changeId()).load();
     return approvalsUtil.getSubmitter(notes, patchSetId);
   }
 
   private void assertSubmitApproval(PatchSet.Id patchSetId) throws Exception {
     PatchSetApproval a = getSubmitter(patchSetId);
     assertThat(a.isLegacySubmit()).isTrue();
-    assertThat(a.getValue()).isEqualTo((short) 1);
-    assertThat(a.getAccountId()).isEqualTo(admin.id());
+    assertThat(a.value()).isEqualTo((short) 1);
+    assertThat(a.accountId()).isEqualTo(admin.id());
   }
 
   private void assertCommit(Project.NameKey project, String branch) throws Exception {
@@ -381,4 +504,45 @@
         pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content, changeId);
     return push.to(ref);
   }
+
+  /**
+   * Makes sure that two emails are sent: one for the change creation, and one for the submit.
+   *
+   * @param expected The account expected to receive message.
+   * @param expectedRecipientType The notification's type: To/Cc/Bcc. if {@code null} then it is not
+   *     needed to check the recipientType. It is meant for -notify without other flags like
+   *     notify-cc, notify-to, and notify-bcc. With the -notify flag, the message can sometimes be
+   *     sent as "To" and sometimes can be sent as "Cc".
+   */
+  private void assertThatEmailsForChangeCreationAndSubmitWereSent(
+      TestAccount expected, @Nullable RecipientType expectedRecipientType) {
+    String expectedEmail = expected.email();
+    String expectedFullName = expected.fullName();
+    Address expectedAddress = new Address(expectedFullName, expectedEmail);
+    assertThat(sender.getMessages()).hasSize(2);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body().contains("review")).isTrue();
+    assertAddress(message, expectedAddress, expectedRecipientType);
+    message = sender.getMessages().get(1);
+    assertThat(message.rcpt()).containsExactly(expectedAddress);
+    assertAddress(message, expectedAddress, expectedRecipientType);
+    assertThat(message.body().contains("submitted")).isTrue();
+  }
+
+  private void assertAddress(
+      Message message, Address expectedAddress, @Nullable RecipientType expectedRecipientType) {
+    assertThat(message.rcpt()).containsExactly(expectedAddress);
+    if (expectedRecipientType != null
+        && expectedRecipientType
+            != RecipientType.BCC) { // When Bcc, it does not appear in the header.
+      String expectedRecipientTypeString = "To";
+      if (expectedRecipientType == RecipientType.CC) {
+        expectedRecipientTypeString = "Cc";
+      }
+      assertThat(
+              ((EmailHeader.AddressList) message.headers().get(expectedRecipientTypeString))
+                  .getAddressList())
+          .containsExactly(expectedAddress);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index d1349d0..01323a0 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
@@ -22,8 +24,8 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
@@ -58,11 +60,12 @@
 
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   protected TestRepository<?> superRepo;
   protected Project.NameKey superKey;
   protected TestRepository<?> subRepo;
   protected Project.NameKey subKey;
-  @Inject protected ProjectOperations projectOperations;
 
   protected SubmitType getSubmitType() {
     return cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
@@ -104,8 +107,12 @@
   }
 
   protected void grantPush(Project.NameKey project) throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
   }
 
   protected Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
@@ -472,7 +479,7 @@
         ObjectInserter ins = serverRepo.newObjectInserter();
         RevWalk rw = new RevWalk(serverRepo)) {
       Ref ref = serverRepo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
+      assertWithMessage(refName).that(ref).isNotNull();
       ObjectId oldCommitId = ref.getObjectId();
 
       DirCache dc = DirCache.newInCore();
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 24a83e0..ef54c92 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -8,6 +8,8 @@
     deps = [
         ":push_for_review",
         ":submodule_util",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/server/git/receive/testing",
         "//lib/commons:lang",
     ],
 ) for f in glob(["*IT.java"])]
@@ -18,6 +20,7 @@
     srcs = glob(["Abstract*.java"]),
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/mail",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
index ac0cbd8..e2aa666 100644
--- a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -50,8 +53,15 @@
         .add(".gitmodules", config.toText())
         .create();
 
-    exception.expectMessage(expectedErrorMessage);
-    exception.expect(TransportException.class);
-    repo.git().push().setRemote("origin").setRefSpecs(new RefSpec("HEAD:refs/for/master")).call();
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () ->
+                repo.git()
+                    .push()
+                    .setRemote("origin")
+                    .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
+                    .call());
+    assertThat(thrown).hasMessageThat().contains(expectedErrorMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 6516b32..b51263e 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -19,8 +19,9 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.git.ObjectIds;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -76,8 +77,9 @@
     assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
-  private static String implicitMergeOf(ObjectId commit) {
-    return "implicit merge of " + commit.abbreviate(7).name();
+  private String implicitMergeOf(ObjectId commit) throws Exception {
+    return "implicit merge of "
+        + ObjectIds.abbreviateName(commit, testRepo.getRevWalk().getObjectReader());
   }
 
   private void setRejectImplicitMerges() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
new file mode 100644
index 0000000..2f677e2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -0,0 +1,778 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.ProjectWatches;
+import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.EnumSet;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Test;
+
+/** Tests account behavior when users push to accounts refs. */
+public class PushAccountIT extends AbstractDaemonTest {
+
+  @ConfigSuite.Default
+  public static Config enableSignedPushConfig() {
+    return defaultConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config disableInMemoryRefCache() {
+    // Run these tests for both enabled and disabled in-memory ref caches. This is an implementation
+    // detail of ReceiveCommits that makes the logic either base it's computation on previously
+    // advertised refs or a make it query a ref database.
+    Config cfg = defaultConfig();
+    cfg.setBoolean("receive", null, "enableInMemoryRefCache", false);
+    return cfg;
+  }
+
+  private static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("receive", null, "enableSignedPush", true);
+
+    // Disable the staleness checker so that tests that verify the number of expected index events
+    // are stable.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
+    return cfg;
+  }
+
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Sequences seq;
+
+  @Test
+  public void pushToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      push.to(RefNames.refsUsers(admin.id())).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+  }
+
+  @Test
+  public void pushToUserBranchForReview() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRefName = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRefName + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(admin.email());
+      assertThat(info.name).isEqualTo(admin.fullName());
+      assertThat(info.status).isEqualTo("out-of-office");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String email = "some.email@example.com";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  foo.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      requestScopeOperations.setApiUser(foo.id());
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(email);
+      assertThat(info.name).isEqualTo(foo.fullName());
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  "invalid config")
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "invalid account configuration: commit '%s' has an invalid '%s' file for account"
+                      + " '%s': Invalid config file %s in commit %s",
+                  r.getCommit().name(),
+                  AccountProperties.ACCOUNT_CONFIG,
+                  admin.id(),
+                  AccountProperties.ACCOUNT_CONFIG,
+                  r.getCommit().name()));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String noEmail = "no.email";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "invalid account configuration: invalid preferred email '%s' for account '%s'",
+                  noEmail, admin.id()));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("invalid account configuration: cannot deactivate own account");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
+
+      TestAccount foo = accountCreator.create(name("foo"));
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .add(allowLabel("Code-Review").ref(userRef).group(adminGroupUuid()).range(-2, 2))
+          .add(allow(Permission.SUBMIT).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
+    }
+  }
+
+  @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config wc = new Config();
+      wc.setString(
+          ProjectWatches.PROJECT,
+          project.get(),
+          ProjectWatches.KEY_NOTIFY,
+          ProjectWatches.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Add project watch",
+              ProjectWatches.WATCH_CONFIG,
+              wc.toText());
+      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      String invalidNotifyValue = "]invalid[";
+      wc.setString(
+          ProjectWatches.PROJECT, project.get(), ProjectWatches.KEY_NOTIFY, invalidNotifyValue);
+      push =
+          pushFactory.create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Add invalid project watch",
+              ProjectWatches.WATCH_CONFIG,
+              wc.toText());
+      PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format(
+              "%s: Invalid project watch of account %d for project %s: %s",
+              ProjectWatches.WATCH_CONFIG, admin.id().get(), project.get(), invalidNotifyValue));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
+      requestScopeOperations.setApiUser(oooUser.id());
+
+      // Must clone as oooUser to ensure the push is allowed.
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
+      fetch(allUsersRepo, RefNames.refsUsers(oooUser.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
+
+      accountIndexedCounter.clear();
+      pushFactory
+          .create(
+              oooUser.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(RefNames.refsUsers(oooUser.id()))
+          .assertOkStatus();
+
+      accountIndexedCounter.assertReindexOf(oooUser);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(oooUser.email());
+      assertThat(info.name).isEqualTo(oooUser.fullName());
+      assertThat(info.status).isEqualTo("out-of-office");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  "invalid config")
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format(
+              "commit '%s' has an invalid '%s' file for account '%s':"
+                  + " Invalid config file %s in commit %s",
+              r.getCommit().name(),
+              AccountProperties.ACCOUNT_CONFIG,
+              admin.id(),
+              AccountProperties.ACCOUNT_CONFIG,
+              r.getCommit().name()));
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String noEmail = "no.email";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id()));
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+
+      String noEmail = "no.email";
+      accountsUpdateProvider
+          .get()
+          .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(REGISTERED_USERS))
+          .update();
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String status = "in vacation";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, status);
+
+      pushFactory
+          .create(
+              foo.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      assertThat(info.email).isEqualTo(noEmail);
+      assertThat(info.name).isEqualTo(foo.fullName());
+      assertThat(info.status).isEqualTo(status);
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String email = "some.email@example.com";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
+
+      pushFactory
+          .create(
+              foo.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      assertThat(info.email).isEqualTo(email);
+      assertThat(info.name).isEqualTo(foo.fullName());
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage("cannot deactivate own account");
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
+
+      TestAccount foo = accountCreator.create(name("foo"));
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      pushFactory
+          .create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
+    }
+  }
+
+  @Test
+  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.REFS_USERS + "foo";
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void cannotCreateUserBranch() throws Exception {
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
+    Config ac = new Config();
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            allUsersRepo.getRepository(),
+            AccountProperties.ACCOUNT_CONFIG,
+            getHead(allUsersRepo.getRepository(), "HEAD").getTree())) {
+      assertThat(tw).isNotNull();
+      ac.fromText(
+          new String(
+              allUsersRepo
+                  .getRevWalk()
+                  .getObjectReader()
+                  .open(tw.getObjectId(0), OBJ_BLOB)
+                  .getBytes(),
+              UTF_8));
+    }
+    return ac;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index cc19ad2..f9c751f 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.git.testing.PushResultSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
@@ -24,19 +24,17 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.function.Consumer;
@@ -54,14 +52,13 @@
 import org.junit.Test;
 
 public class PushPermissionsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
       ProjectConfig cfg = u.getConfig();
-      cfg.getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
 
       // Remove push-related permissions, so they can be added back individually by test methods.
       removeAllBranchPermissions(
@@ -73,13 +70,15 @@
           Permission.PUSH_MERGE,
           Permission.SUBMIT);
       removeAllGlobalCapabilities(cfg, GlobalCapability.ADMINISTRATE_SERVER);
-
-      // Include some auxiliary permissions.
-      Util.allow(cfg, Permission.FORGE_AUTHOR, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
-
       u.save();
     }
+
+    // Include some auxiliary permissions.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @Test
@@ -94,17 +93,6 @@
   }
 
   @Test
-  public void mixingDirectChangesAndRegularPush() throws Exception {
-    testRepo.branch("HEAD").commit().create();
-    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/changes/01/101");
-
-    String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
-    assertThat(r.getRemoteUpdate("refs/changes/01/101")).isNotEqualTo(Status.OK);
-    assertThat(r.getRemoteUpdate("refs/heads/master").getMessage()).isEqualTo(msg);
-  }
-
-  @Test
   public void fastForwardUpdateDenied() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master");
@@ -202,7 +190,11 @@
 
   @Test
   public void refsMetaConfigUpdateRequiresProjectOwner() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
 
     forceFetch("refs/meta/config");
     ObjectId commit = testRepo.branch("refs/meta/config").commit().create();
@@ -222,7 +214,11 @@
             "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
 
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Re-fetch refs/meta/config from the server because the grant changed it, and we want a
     // fast-forward.
@@ -249,9 +245,14 @@
 
   @Test
   public void updateBySubmitDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ObjectId commit = testRepo.branch("HEAD").commit().create();
+    ObjectId commit =
+        testRepo.branch("HEAD").commit().message("test commit").insertChangeId().create();
     assertThat(push("HEAD:refs/for/master")).onlyRef("refs/for/master").isOk();
     gApi.changes().id(commit.name()).current().review(ReviewInput.approve());
 
@@ -267,18 +268,23 @@
 
   @Test
   public void addPatchSetDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput ci = new ChangeInput();
     ci.project = project.get();
     ci.branch = "master";
     ci.subject = "A change";
-    Change.Id id = new Change.Id(gApi.changes().create(ci).get()._number);
+    Change.Id id = Change.id(gApi.changes().create(ci).get()._number);
 
     requestScopeOperations.setApiUser(admin.id());
-    ObjectId ps1Id = forceFetch(new PatchSet.Id(id, 1).toRefName());
+    ObjectId ps1Id = forceFetch(PatchSet.id(id, 1).toRefName());
     ObjectId ps2Id = testRepo.amend(ps1Id).add("file", "content").create();
     PushResult r = push(ps2Id.name() + ":refs/for/master");
+    // Admin had ADD_PATCH_SET removed in setup.
     assertThat(r)
         .onlyRef("refs/for/master")
         .isRejected("cannot add patch set to " + id.get() + ".");
@@ -288,7 +294,11 @@
 
   @Test
   public void skipValidationDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
@@ -305,7 +315,11 @@
 
   @Test
   public void accessDatabaseForNoteDbDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
@@ -322,8 +336,12 @@
 
   @Test
   public void administrateServerForUpdateParentDenied() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     String project2 = name("project2");
     gApi.projects().create(project2);
@@ -398,7 +416,7 @@
       case REJECTED_OTHER_REASON:
       case RENAMED:
       default:
-        assert_().fail("fetch failed to update local %s: %s", ref, u.getResult());
+        assertWithMessage("fetch failed to update local %s: %s", ref, u.getResult()).fail();
         break;
     }
     return u.getNewObjectId();
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 5bed77c..ea9f48e 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,11 +18,14 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -30,28 +33,29 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHook;
+import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
+import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -71,6 +75,9 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -79,11 +86,15 @@
   @Inject private AllUsersName allUsersName;
   @Inject private ChangeNoteUtil noteUtil;
   @Inject private PermissionBackend permissionBackend;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
 
+  private RevCommit rcMaster;
+  private RevCommit rcBranch;
+
   private ChangeData cd1;
   private String psRef1;
   private String metaRef1;
@@ -122,9 +133,12 @@
       for (AccessSection sec : u.getConfig().getAccessSections()) {
         sec.removePermission(Permission.READ);
       }
-      Util.allow(u.getConfig(), Permission.READ, admins, "refs/*");
       u.save();
     }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(admins))
+        .update();
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
@@ -135,60 +149,96 @@
     }
   }
 
+  // Building the following:
+  //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
+  //      \                                    \
+  //    (c3_open)                            (c4_open)
+  //
   private void setUpChanges() throws Exception {
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
     // First 2 changes are merged, which means the tags pointing to them are
     // visible.
-    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(admins))
+        .update();
+
+    //   rcMaster (c1 master)
     PushOneCommit.Result mr =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%submit");
     mr.assertOkStatus();
     cd1 = mr.getChange();
-    psRef1 = cd1.currentPatchSet().getId().toRefName();
+    rcMaster = mr.getCommit();
+    psRef1 = cd1.currentPatchSet().id().toRefName();
     metaRef1 = RefNames.changeMetaRef(cd1.getId());
+
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
     PushOneCommit.Result br =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch%submit");
     br.assertOkStatus();
     cd2 = br.getChange();
-    psRef2 = cd2.currentPatchSet().getId().toRefName();
+    rcBranch = br.getCommit();
+    psRef2 = cd2.currentPatchSet().id().toRefName();
     metaRef2 = RefNames.changeMetaRef(cd2.getId());
 
     // Second 2 changes are unmerged.
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
+    //      \
+    //    (c3_open)
+    //
     mr = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     mr.assertOkStatus();
     cd3 = mr.getChange();
-    psRef3 = cd3.currentPatchSet().getId().toRefName();
+    psRef3 = cd3.currentPatchSet().id().toRefName();
     metaRef3 = RefNames.changeMetaRef(cd3.getId());
+
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
+    //      \                        \
+    //     (c3_open)                (c4_open)
     br = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch");
     br.assertOkStatus();
     cd4 = br.getChange();
-    psRef4 = cd4.currentPatchSet().getId().toRefName();
+    psRef4 = cd4.currentPatchSet().id().toRefName();
     metaRef4 = RefNames.changeMetaRef(cd4.getId());
 
     try (Repository repo = repoManager.openRepository(project)) {
-      // master-tag -> master
+      //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch)
+      //       \                                  \
+      //     (c3_open)                          (c4_open)
       RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
       mtu.setExpectedOldObjectId(ObjectId.zeroId());
       mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
       assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
 
-      // branch-tag -> branch
+      //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
+      //       \                                  \
+      //     (c3_open)                          (c4_open)
       RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
       assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      // Create a tag for the tree of the commit on 'master'
+      // tree-tag -> master.tree
+      RefUpdate ttu = repo.updateRef("refs/tags/tree-tag");
+      ttu.setExpectedOldObjectId(ObjectId.zeroId());
+      ttu.setNewObjectId(rcMaster.getTree().toObjectId());
+      assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW);
     }
   }
 
   @Test
+  @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "false")
   public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.READ, admins, RefNames.REFS_CONFIG);
-      Util.doNotInherit(u.getConfig(), Permission.READ, RefNames.REFS_CONFIG);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(admins))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref(RefNames.REFS_CONFIG), true)
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -205,12 +255,46 @@
         "refs/heads/master",
         "refs/tags/branch-tag",
         "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
+  @Test
+  @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "true")
+  public void uploadPackAllRefsVisibleNoRefsMetaConfigSkipFullRefEval() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(admins))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref(RefNames.REFS_CONFIG), true)
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
   }
 
   @Test
   public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
-    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
 
     assertUploadPackRefs(
         "HEAD",
@@ -226,23 +310,46 @@
         "refs/heads/master",
         RefNames.REFS_CONFIG,
         "refs/tags/branch-tag",
-        "refs/tags/master-tag");
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
+  }
+
+  @Test
+  public void grantReadOnRefsTagsIsNoOp() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(); // We expect no refs returned
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
         "HEAD", psRef1, metaRef1, psRef3, metaRef3, "refs/heads/master", "refs/tags/master-tag");
+    // tree-tag is not visible because we don't look at trees reachable from
+    // refs
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -255,11 +362,16 @@
         // master branch is not visible but master-tag is reachable from branch
         // (since PushOneCommit always bases changes on each other).
         "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit is not visible.
     requestScopeOperations.setApiUser(admin.id());
@@ -278,12 +390,17 @@
         "refs/heads/master",
         "refs/tags/master-tag",
         "refs/users/01/1000001/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit on change3 is visible.
     requestScopeOperations.setApiUser(admin.id());
@@ -306,13 +423,21 @@
         "refs/tags/master-tag",
         "refs/users/00/1000000/edit-" + cd3.getId() + "/1",
         "refs/users/01/1000001/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(cd3.getId().get()).edit().create();
@@ -335,6 +460,7 @@
         "refs/tags/master-tag",
         // All edits are visible due to accessDatabase capability.
         "refs/users/00/1000000/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
@@ -349,7 +475,11 @@
   }
 
   private void uploadPackNoSearchingChangeCacheImpl() throws Exception {
-    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertRefs(
@@ -370,21 +500,29 @@
         "refs/heads/master",
         "refs/tags/branch-tag",
         "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
     assertRefs(allProjects, user, true);
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     assertRefs(allProjects, user, true, "refs/sequences/changes");
   }
 
   @Test
   public void uploadPackAllRefsAreVisibleOrphanedTag() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     // Delete the pending change on 'branch' and 'branch' itself so that the tag gets orphaned
-    gApi.changes().id(cd4.getId().id).delete();
+    gApi.changes().id(cd4.getId().get()).delete();
     gApi.projects().name(project.get()).branch("refs/heads/branch").delete();
 
     requestScopeOperations.setApiUser(user.id());
@@ -399,28 +537,517 @@
         metaRef3,
         "refs/heads/master",
         "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetRefsVisibleOrphanedTagInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+    // Create a tag for the pending change on 'branch' so that the tag is orphaned
+    try (Repository repo = repoManager.openRepository(project)) {
+      // change4-tag -> psRef4
+      RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
+      ctu.setExpectedOldObjectId(ObjectId.zeroId());
+      ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
+      assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcMaster (c1 master)
+  // second ls-remote: rcMaster (c1 master) <- newchange1 (master-newtag)
+  @Test
+  public void uploadPackNewCommitOrphanTagInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // rcMaster (c1 master)
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result r =
+          pushFactory.create(admin.newIdent(), testRepo).setParent(rcMaster).to("refs/for/master");
+      r.assertOkStatus();
+
+      // rcMaster (c1 master) <- newchange1 (master-newtag)
+      RefUpdate btu = repo.updateRef("refs/tags/master-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(r.getCommit());
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2) <- newcommit1                 <- newcommit2 (branch)
+  // second ls-remote: rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+  @Test
+  public void uploadPackNewReachableTagVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // c2 <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // c2 <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // c2 <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/branch-newtag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2) <- newcommit1 (branch)
+  // second ls-remote: rcBranch (c2) <- newcommit1                 <- newcommit2 (branch)
+  // third  ls-remote: rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+  @Test
+  public void uploadPackBranchFFNewTagOldBranchVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2) <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // rcBranch (c2) <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+
+      // rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/branch-newtag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2)        <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (branch-oldtag)
+  @Test
+  public void uploadPackBranchRewindMakeTagUnreachableInVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2) <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // rcBranch (c2) <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit bRc = r.getCommit();
+
+      // rcBranch (c2) <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-oldtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/branch-oldtag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // rcBranch (c2 branch) <- newcommit1 (branch-oldtag) <- newcommit2
+      btu = repo.updateRef("refs/heads/branch");
+      btu.setExpectedOldObjectId(bRc);
+      btu.setNewObjectId(rcBranch);
+      btu.setForceUpdate(true);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+  @Test
+  public void uploadPackCreateBranchTagReachableVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch) <- newcommit1 (branch-newtag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/new-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs();
+
+      // rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(tagRc)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        "refs/heads/new-branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/new-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch)               <- newcommit1 (updated-tag)
+  // second ls-remote: rcBranch (c2 branch updated-tag)
+  @Test
+  public void uploadPackTagUpdatedReachableVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch) <- newcommit1 (updated-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/updated-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag");
+
+      // rcBranch (c2 branch updated-tag)
+      RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
+      btu.setExpectedOldObjectId(tagRc);
+      btu.setNewObjectId(rcBranch);
+      btu.setForceUpdate(true);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/updated-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch updated-tag)
+  // second ls-remote: rcBranch (c2 branch)             <- newcommit1 (updated-tag)
+  @Test
+  public void uploadPackTagUpdatedUnreachableInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch updated-tag)
+      RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(rcBranch);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag",
+          "refs/tags/updated-tag");
+
+      // rcBranch (c2 branch) <- newcommit1 (updated-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/updated-tag");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch branch-tag)
+  // second ls-remote: rcBranch (c2 branch)
+  @Test
+  public void uploadPackTagDeleted() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.DELETE).ref("refs/tags/branch-tag").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/branch-tag").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // rcBranch (c2 branch branch-tag)
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+
+    // rcBranch (c2 branch)
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+      btu.setExpectedOldObjectId(rcBranch);
+      btu.setNewObjectId(ObjectId.zeroId());
+      btu.setForceUpdate(true);
+      assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2, metaRef2, psRef4, metaRef4, "refs/heads/branch", "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag)
+  @Test
+  public void uploadPackBranchDeleteTagUnreachableInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.DELETE).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (branch) <- newcommit1 (new-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/new-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+      r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(tagRc)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/heads/new-branch",
+        "refs/tags/new-tag",
+        "refs/tags/master-tag");
+
+    // rcBranch (c2 branch) <- newcommit1 (new-tag)
+    gApi.projects().name(project.get()).branch("refs/heads/new-branch").delete();
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
         "refs/tags/master-tag");
   }
 
   @Test
   public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
+    TestRefAdvertiser.Result r = getReceivePackRefs();
     assertThat(r.allRefs().keySet())
         .containsExactly(
             // meta refs are excluded
-            "HEAD",
             "refs/heads/branch",
             "refs/heads/master",
             "refs/meta/config",
             "refs/tags/branch-tag",
-            "refs/tags/master-tag");
+            "refs/tags/master-tag",
+            "refs/tags/tree-tag");
     assertThat(r.additionalHaves()).containsExactly(obj(cd3, 1), obj(cd4, 1));
   }
 
   @Test
   public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(cd3, 1));
@@ -442,7 +1069,7 @@
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       String subject = "Subject for missing commit";
       Change c = new Change(cd3.change());
-      PatchSet.Id psId = new PatchSet.Id(cd3.getId(), 2);
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
       c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
 
       PersonIdent committer = serverIdent.get();
@@ -483,7 +1110,11 @@
 
   @Test
   public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
-    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
@@ -493,7 +1124,10 @@
 
   @Test
   public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
@@ -515,7 +1149,11 @@
 
   @Test
   public void advertisedReferencesOmitGroupBranchesOfNonOwnedGroups() throws Exception {
-    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins, user);
     AccountGroup.UUID foos = createGroup("Foos", users);
     AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
@@ -528,7 +1166,10 @@
 
   @Test
   public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
@@ -542,8 +1183,15 @@
 
   @Test
   public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
-    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
@@ -557,7 +1205,11 @@
 
   @Test
   public void advertisedReferencesOmitNoteDbNotesBranches() throws Exception {
-    allow(allUsersName, RefNames.REFS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getRefs(git)).containsNoneOf(RefNames.REFS_EXTERNAL_IDS, RefNames.REFS_GROUPNAMES);
@@ -566,11 +1218,15 @@
 
   @Test
   public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -583,11 +1239,15 @@
     assume()
         .that(baseConfig.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true))
         .isTrue();
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -599,11 +1259,15 @@
   @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "false")
   public void advertisedReferencesOmitPrivateChangesOfOtherUsersWhenShortcutDisabled()
       throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -613,8 +1277,16 @@
 
   @Test
   public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     DraftInput draftInput = new DraftInput();
@@ -633,8 +1305,16 @@
 
   @Test
   public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().starChange(cd3.getId().toString());
@@ -649,7 +1329,10 @@
 
   @Test
   public void hideMetadata() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     // create change
     TestRepository<?> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
@@ -662,7 +1345,6 @@
 
     List<String> expectedNonMetaRefs =
         ImmutableList.of(
-            RefNames.REFS_USERS_SELF,
             RefNames.refsUsers(admin.id()),
             RefNames.refsUsers(user.id()),
             RefNames.REFS_EXTERNAL_IDS,
@@ -719,7 +1401,7 @@
   }
 
   private List<String> getRefs(Git git) throws Exception {
-    return getRefs(git, Predicates.alwaysTrue());
+    return getRefs(git, x -> true);
   }
 
   private List<String> getUserRefs(Git git) throws Exception {
@@ -760,11 +1442,16 @@
     }
   }
 
-  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook hook =
-        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
+  private TestRefAdvertiser.Result getReceivePackRefs() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      return hook.advertiseRefs(getAllRefs(repo));
+      AdvertiseRefsHook adv =
+          ReceiveCommitsAdvertiseRefsHookChain.createForTest(
+              queryProvider, project, identifiedUserFactory.create(admin.id()));
+      ReceivePack rp = new ReceivePack(repo);
+      rp.setAdvertiseRefsHook(adv);
+      TestRefAdvertiser advertiser = new TestRefAdvertiser(repo);
+      rp.sendAdvertisedRefs(advertiser);
+      return advertiser.result();
     }
   }
 
@@ -773,10 +1460,10 @@
   }
 
   private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
-    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet.Id psId = PatchSet.id(cd.getId(), psNum);
     PatchSet ps = cd.patchSet(psId);
     assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
-    return ObjectId.fromString(ps.getRevision().get());
+    return ps.commitId();
   }
 
   private AccountGroup.UUID createSelfOwnedGroup(String name, TestAccount... members)
@@ -792,7 +1479,7 @@
     groupInput.ownerId = ownerGroup != null ? ownerGroup.get() : null;
     groupInput.members =
         Arrays.stream(members).map(m -> String.valueOf(m.id().get())).collect(toList());
-    return new AccountGroup.UUID(gApi.groups().create(groupInput).get().id);
+    return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
   }
 
   private static Map<String, Ref> getAllRefs(Repository repo) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 9b823b7..876e342 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.CREATE;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
@@ -24,11 +26,12 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
@@ -47,17 +50,16 @@
 public class RefOperationValidationIT extends AbstractDaemonTest {
   private static final String TEST_REF = "refs/heads/protected";
 
-  @Inject DynamicSet<RefOperationValidationListener> validators;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
-  private class TestRefValidator implements RefOperationValidationListener, AutoCloseable {
+  private static class TestRefValidator implements RefOperationValidationListener {
     private final ReceiveCommand.Type rejectType;
     private final String rejectRef;
-    private final RegistrationHandle handle;
 
     public TestRefValidator(ReceiveCommand.Type rejectType) {
       this.rejectType = rejectType;
       this.rejectRef = TEST_REF;
-      this.handle = validators.add("test-" + rejectType.name(), this);
     }
 
     @Override
@@ -69,27 +71,35 @@
       }
       return Collections.emptyList();
     }
+  }
 
-    @Override
-    public void close() throws Exception {
-      handle.remove();
-    }
+  private Registration testValidator(ReceiveCommand.Type rejectType) {
+    return extensionRegistry.newRegistration().add(new TestRefValidator(rejectType));
   }
 
   @Test
   public void rejectRefCreation() throws Exception {
-    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
-      gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
-      assert_().fail("expected exception");
-    } catch (RestApiException expected) {
+    try (Registration registration = testValidator(CREATE)) {
+      RestApiException expected =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput()));
       assertThat(expected).hasMessageThat().contains(CREATE.name());
     }
   }
 
+  private void grant(String permission) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(permission).ref("refs/*").group(REGISTERED_USERS).force(true))
+        .update();
+  }
+
   @Test
   public void rejectRefCreationByPush() throws Exception {
-    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
-      grant(project, "refs/*", Permission.PUSH, true);
+    try (Registration registration = testValidator(CREATE)) {
+      grant(Permission.PUSH);
       PushOneCommit push1 =
           pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
       PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -102,10 +112,11 @@
   @Test
   public void rejectRefDeletion() throws Exception {
     gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
-    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
-      gApi.projects().name(project.get()).branch(TEST_REF).delete();
-      assert_().fail("expected exception");
-    } catch (RestApiException expected) {
+    try (Registration registration = testValidator(DELETE)) {
+      RestApiException expected =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.projects().name(project.get()).branch(TEST_REF).delete());
       assertThat(expected).hasMessageThat().contains(DELETE.name());
     }
   }
@@ -113,8 +124,8 @@
   @Test
   public void rejectRefDeletionByPush() throws Exception {
     gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
-    grant(project, "refs/*", Permission.DELETE, true);
-    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
+    grant(Permission.DELETE);
+    try (Registration registration = testValidator(DELETE)) {
       PushResult result = deleteRef(testRepo, TEST_REF);
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(TEST_REF);
       assertThat(refUpdate.getMessage()).contains(DELETE.name());
@@ -124,8 +135,8 @@
   @Test
   public void rejectRefUpdateFastForward() throws Exception {
     gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
-    try (TestRefValidator validator = new TestRefValidator(UPDATE)) {
-      grant(project, "refs/*", Permission.PUSH, true);
+    try (Registration registration = testValidator(UPDATE)) {
+      grant(Permission.PUSH);
       PushOneCommit push1 =
           pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
       PushOneCommit.Result r1 = push1.to(TEST_REF);
@@ -136,9 +147,9 @@
   @Test
   public void rejectRefUpdateNonFastForward() throws Exception {
     gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
-    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
+    try (Registration registration = testValidator(UPDATE_NONFASTFORWARD)) {
       ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-      grant(project, "refs/*", Permission.PUSH, true);
+      grant(Permission.PUSH);
       PushOneCommit push1 =
           pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
       PushOneCommit.Result r1 = push1.to(TEST_REF);
@@ -161,8 +172,8 @@
   public void rejectRefUpdateNonFastForwardToExistingCommit() throws Exception {
     gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
 
-    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
-      grant(project, "refs/*", Permission.PUSH, true);
+    try (Registration registration = testValidator(UPDATE_NONFASTFORWARD)) {
+      grant(Permission.PUSH);
       PushOneCommit push1 =
           pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
       PushOneCommit.Result r1 = push1.to("refs/heads/master");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index e090fae..09da628 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,15 +16,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -45,6 +46,8 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
   public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
@@ -108,7 +111,7 @@
   public void subscriptionWildcardACLForMissingProject() throws Exception {
 
     allowMatchingSubmoduleSubscription(
-        subKey, "refs/heads/*", new Project.NameKey("not-existing-super-project"), "refs/heads/*");
+        subKey, "refs/heads/*", Project.nameKey("not-existing-super-project"), "refs/heads/*");
     pushChangeTo(subRepo, "master");
   }
 
@@ -379,10 +382,7 @@
   @Test
   public void subscriptionFailOnWrongProjectACL() throws Exception {
     allowMatchingSubmoduleSubscription(
-        subKey,
-        "refs/heads/master",
-        new Project.NameKey("wrong-super-project"),
-        "refs/heads/master");
+        subKey, "refs/heads/master", Project.nameKey("wrong-super-project"), "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
     createSubmoduleSubscription(superRepo, "master", subKey, "master");
@@ -479,127 +479,107 @@
   }
 
   @Test
+  @UseClockStep
   public void superRepoCommitHasSameAuthorAsSubmoduleCommit() throws Exception {
     // Make sure that the commit is created at an earlier timestamp than the submit timestamp.
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    try {
-      allowMatchingSubmoduleSubscription(
-          subKey, "refs/heads/master", superKey, "refs/heads/master");
-      createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
-      PushOneCommit.Result pushResult =
-          createChange(subRepo, "refs/heads/master", "Change", "a.txt", "some content", null);
-      approve(pushResult.getChangeId());
-      gApi.changes().id(pushResult.getChangeId()).current().submit();
+    PushOneCommit.Result pushResult =
+        createChange(subRepo, "refs/heads/master", "Change", "a.txt", "some content", null);
+    approve(pushResult.getChangeId());
+    gApi.changes().id(pushResult.getChangeId()).current().submit();
 
-      // Expect that the author name/email is preserved for the superRepo commit, but a new author
-      // timestamp is used.
-      PersonIdent authorIdent = getAuthor(superRepo, "master");
-      assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
-      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
-      assertThat(authorIdent.getWhen())
-          .isGreaterThan(pushResult.getCommit().getAuthorIdent().getWhen());
-    } finally {
-      TestTimeUtil.useSystemTime();
-    }
+    // Expect that the author name/email is preserved for the superRepo commit, but a new author
+    // timestamp is used.
+    PersonIdent authorIdent = getAuthor(superRepo, "master");
+    assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
+    assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
+    assertThat(authorIdent.getWhen())
+        .isGreaterThan(pushResult.getCommit().getAuthorIdent().getWhen());
   }
 
   @Test
+  @UseClockStep
   public void superRepoCommitHasSameAuthorAsSubmoduleCommits() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
-    // Make sure that the commits are created at different timestamps and that the submit timestamp
-    // is afterwards.
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    try {
+    Project.NameKey proj2 = createProjectForPush(getSubmitType());
 
-      Project.NameKey proj2 = createProjectForPush(getSubmitType());
+    TestRepository<?> subRepo2 = cloneProject(proj2);
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
 
-      TestRepository<?> subRepo2 = cloneProject(proj2);
-      allowMatchingSubmoduleSubscription(
-          subKey, "refs/heads/master", superKey, "refs/heads/master");
-      allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
+    prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
+    pushSubmoduleConfig(superRepo, "master", config);
 
-      Config config = new Config();
-      prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
-      prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
-      pushSubmoduleConfig(superRepo, "master", config);
+    String topic = "foo";
 
-      String topic = "foo";
+    PushOneCommit.Result pushResult1 =
+        createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+    approve(pushResult1.getChangeId());
 
-      PushOneCommit.Result pushResult1 =
-          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
-      approve(pushResult1.getChangeId());
+    PushOneCommit.Result pushResult2 =
+        createChange(subRepo2, "refs/heads/master", "Change 2", "b.txt", "other content", topic);
+    approve(pushResult2.getChangeId());
 
-      PushOneCommit.Result pushResult2 =
-          createChange(subRepo2, "refs/heads/master", "Change 2", "b.txt", "other content", topic);
-      approve(pushResult2.getChangeId());
+    // Submit the topic, 2 changes with the same author.
+    gApi.changes().id(pushResult1.getChangeId()).current().submit();
 
-      // Submit the topic, 2 changes with the same author.
-      gApi.changes().id(pushResult1.getChangeId()).current().submit();
-
-      // Expect that the author name/email is preserved for the superRepo commit, but a new author
-      // timestamp is used.
-      PersonIdent authorIdent = getAuthor(superRepo, "master");
-      assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
-      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
-      assertThat(authorIdent.getWhen())
-          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
-      assertThat(authorIdent.getWhen())
-          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
-    } finally {
-      TestTimeUtil.useSystemTime();
-    }
+    // Expect that the author name/email is preserved for the superRepo commit, but a new author
+    // timestamp is used.
+    PersonIdent authorIdent = getAuthor(superRepo, "master");
+    assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
+    assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
+    assertThat(authorIdent.getWhen())
+        .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+    assertThat(authorIdent.getWhen())
+        .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
   }
 
   @Test
+  @UseClockStep
   public void superRepoCommitHasGerritAsAuthorIfAuthorsOfSubmoduleCommitsDiffer() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
-    // Make sure that the commits are created at different timestamps and that the submit timestamp
-    // is afterwards.
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    try {
-      Project.NameKey proj2 = createProjectForPush(getSubmitType());
-      TestRepository<InMemoryRepository> repo2 = cloneProject(proj2, user);
+    Project.NameKey proj2 = createProjectForPush(getSubmitType());
+    TestRepository<InMemoryRepository> repo2 = cloneProject(proj2, user);
 
-      allowMatchingSubmoduleSubscription(
-          subKey, "refs/heads/master", superKey, "refs/heads/master");
-      allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
 
-      Config config = new Config();
-      prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
-      prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
-      pushSubmoduleConfig(superRepo, "master", config);
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
+    prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
+    pushSubmoduleConfig(superRepo, "master", config);
 
-      String topic = "foo";
+    String topic = "foo";
 
-      // Create change as admin.
-      PushOneCommit.Result pushResult1 =
-          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
-      approve(pushResult1.getChangeId());
+    // Create change as admin.
+    PushOneCommit.Result pushResult1 =
+        createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+    approve(pushResult1.getChangeId());
 
-      // Create change as user.
-      PushOneCommit push =
-          pushFactory.create(user.newIdent(), repo2, "Change 2", "b.txt", "other content");
-      PushOneCommit.Result pushResult2 = push.to("refs/for/master/" + name(topic));
-      approve(pushResult2.getChangeId());
+    // Create change as user.
+    PushOneCommit push =
+        pushFactory.create(user.newIdent(), repo2, "Change 2", "b.txt", "other content");
+    PushOneCommit.Result pushResult2 = push.to("refs/for/master/" + name(topic));
+    approve(pushResult2.getChangeId());
 
-      // Submit the topic, 2 changes with the different author.
-      gApi.changes().id(pushResult1.getChangeId()).current().submit();
+    // Submit the topic, 2 changes with the different author.
+    gApi.changes().id(pushResult1.getChangeId()).current().submit();
 
-      // Expect that the Gerrit server identity is chosen as author for the superRepo commit and a
-      // new author timestamp is used.
-      PersonIdent authorIdent = getAuthor(superRepo, "master");
-      assertThat(authorIdent.getName()).isEqualTo(serverIdent.get().getName());
-      assertThat(authorIdent.getEmailAddress()).isEqualTo(serverIdent.get().getEmailAddress());
-      assertThat(authorIdent.getWhen())
-          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
-      assertThat(authorIdent.getWhen())
-          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
-    } finally {
-      TestTimeUtil.useSystemTime();
-    }
+    // Expect that the Gerrit server identity is chosen as author for the superRepo commit and a
+    // new author timestamp is used.
+    PersonIdent authorIdent = getAuthor(superRepo, "master");
+    assertThat(authorIdent.getName()).isEqualTo(serverIdent.get().getName());
+    assertThat(authorIdent.getEmailAddress()).isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(authorIdent.getWhen())
+        .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+    assertThat(authorIdent.getWhen())
+        .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index dc84d13..283c95f 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -17,17 +17,23 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
 import java.util.ArrayDeque;
 import java.util.Map;
 import org.apache.commons.lang.RandomStringUtils;
@@ -67,6 +73,8 @@
     return submitByRebaseIfNecessaryConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void subscriptionUpdateOfManyChanges() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
@@ -136,7 +144,7 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -152,8 +160,8 @@
     // As the submodules have changed commits, the superproject tree will be
     // different, so we cannot directly compare the trees here, so make
     // assumptions only about the changed branches:
-    assertThat(preview).containsKey(new Branch.NameKey(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(new Branch.NameKey(subKey, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
 
     if ((getSubmitType() == SubmitType.CHERRY_PICK)
         || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
@@ -282,8 +290,12 @@
               .name(prefix + "sub" + i)
               .submitType(getSubmitType())
               .create();
-      grant(subKey[i], "refs/heads/*", Permission.PUSH);
-      grant(subKey[i], "refs/for/refs/heads/*", Permission.SUBMIT);
+      projectOperations
+          .project(subKey[i])
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+          .update();
       sub[i] = cloneProject(subKey[i]);
     }
 
@@ -393,7 +405,7 @@
     gApi.changes().id(subChangeId).current().submit();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepo, "master");
-    RevCommit superHead = getRemoteHead(superKey, "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).contains("some message");
     assertThat(superHead.getId()).isNotEqualTo(superId);
   }
@@ -427,7 +439,7 @@
 
     gApi.changes().id(subChangeId).current().submit();
 
-    RevCommit superHead = getRemoteHead(superKey, "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).isEqualTo("some message");
     assertThat(superHead.getId()).isEqualTo(superId);
   }
@@ -614,7 +626,7 @@
     expectToHaveSubmoduleState(topRepo, "master", botKey, bottomRepo, "master");
   }
 
-  private String prepareBranchCircularSubscription() throws Exception {
+  private void testBranchCircularSubscription(ThrowingConsumer<String> apiCall) throws Exception {
     Project.NameKey topKey = createProjectForPush(getSubmitType());
     Project.NameKey midKey = createProjectForPush(getSubmitType());
     Project.NameKey botKey = createProjectForPush(getSubmitType());
@@ -634,23 +646,24 @@
     String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
 
     approve(changeId);
-    exception.expectMessage("Branch level circular subscriptions detected");
-    exception.expectMessage(topKey.get() + ",refs/heads/master");
-    exception.expectMessage(midKey.get() + ",refs/heads/master");
-    exception.expectMessage(botKey.get() + ",refs/heads/master");
-    return changeId;
+
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> apiCall.accept(changeId));
+    assertThat(thrown).hasMessageThat().contains("Branch level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(topKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(midKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(botKey.get() + ",refs/heads/master");
   }
 
   @Test
   public void branchCircularSubscription() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submit();
+    testBranchCircularSubscription(changeId -> gApi.changes().id(changeId).current().submit());
   }
 
   @Test
   public void branchCircularSubscriptionPreview() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submitPreview();
+    testBranchCircularSubscription(
+        changeId -> gApi.changes().id(changeId).current().submitPreview());
   }
 
   @Test
@@ -672,10 +685,13 @@
     approve(getChangeId(subRepo, subMasterHead).get());
     approve(getChangeId(superRepo, superDevHead).get());
 
-    exception.expectMessage("Project level circular subscriptions detected");
-    exception.expectMessage(subKey.get());
-    exception.expectMessage(superKey.get());
-    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
+    Throwable thrown =
+        assertThrows(
+            Throwable.class,
+            () -> gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit());
+    assertThat(thrown).hasMessageThat().contains("Project level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(subKey.get());
+    assertThat(thrown).hasMessageThat().contains(superKey.get());
   }
 
   @Test
@@ -739,13 +755,13 @@
     approve(getChangeId(repoB, bDevHead).get());
 
     gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
-    assertThat(getRemoteHead(keyA, "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/master").getShortMessage())
         .contains("some message in a master.txt");
-    assertThat(getRemoteHead(keyA, "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in a dev.txt");
-    assertThat(getRemoteHead(keyB, "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/master").getShortMessage())
         .contains("some message in b master.txt");
-    assertThat(getRemoteHead(keyB, "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in b dev.txt");
   }
 
@@ -838,13 +854,13 @@
 
     sub1.git().fetch().call();
     RevWalk rw1 = sub1.getRevWalk();
-    RevCommit master1 = rw1.parseCommit(getRemoteHead(subKey1, "master"));
+    RevCommit master1 = rw1.parseCommit(projectOperations.project(subKey1).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
     assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
 
     sub2.git().fetch().call();
     RevWalk rw2 = sub2.getRevWalk();
-    RevCommit master2 = rw2.parseCommit(getRemoteHead(subKey2, "master"));
+    RevCommit master2 = rw2.parseCommit(projectOperations.project(subKey2).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
     assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
 
@@ -898,6 +914,6 @@
   }
 
   private Project.NameKey nameKey(String s) {
-    return new Project.NameKey(name(s));
+    return Project.nameKey(name(s));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index c07d512..cad0b83 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.pgm;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.StreamSubject.streams;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -26,11 +27,11 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.pgm.IndexUpgradeController.UpgradeAttempt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -73,13 +74,13 @@
           .containsExactly(changeId);
       // Query account index
       assertThat(gApi.accounts().query("admin").get().stream().map(a -> a._accountId))
-          .containsExactly(adminId.get());
+          .containsExactly(admin.id().get());
       // Query group index
       assertThat(
               gApi.groups().query("Group").withOption(MEMBERS).get().stream()
                   .flatMap(g -> g.members.stream())
                   .map(a -> a._accountId))
-          .containsExactly(adminId.get());
+          .containsExactly(admin.id().get());
       // Query project index
       assertThat(gApi.projects().query(project.get()).get().stream().map(p -> p.name))
           .containsExactly(project.get());
@@ -195,7 +196,7 @@
       // Updating and searching old schema version works.
       Provider<InternalChangeQuery> queryProvider =
           ctx.getInjector().getProvider(InternalChangeQuery.class);
-      assertThat(queryProvider.get().byKey(new Change.Key(changeId))).hasSize(1);
+      assertThat(queryProvider.get().byKey(Change.key(changeId))).hasSize(1);
       assertThat(queryProvider.get().byTopicOpen("topic1")).isEmpty();
 
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -223,7 +224,7 @@
   }
 
   private void setUpChange() throws Exception {
-    project = new Project.NameKey("reindex-project-test");
+    project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
       configureIndex(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -240,7 +241,7 @@
   }
 
   private void enableSlaveMode() throws Exception {
-    updateConfig(config -> config.setBoolean("container", null, "slave", true));
+    updateConfig(config -> config.setBoolean("container", null, "replica", true));
   }
 
   private void updateConfig(Consumer<Config> configConsumer) throws Exception {
@@ -255,30 +256,31 @@
   }
 
   private void assertSearchVersion(ServerContext ctx, int expected) {
-    assertThat(
+    assertWithMessage("search version")
+        .that(
             ctx.getInjector()
                 .getInstance(ChangeIndexCollection.class)
                 .getSearchIndex()
                 .getSchema()
                 .getVersion())
-        .named("search version")
         .isEqualTo(expected);
   }
 
   private void assertWriteVersions(ServerContext ctx, Integer... expected) {
-    assertThat(
+    assertWithMessage("write versions")
+        .about(streams())
+        .that(
             ctx.getInjector().getInstance(ChangeIndexCollection.class).getWriteIndexes().stream()
                 .map(i -> i.getSchema().getVersion()))
-        .named("write versions")
         .containsExactlyElementsIn(ImmutableSet.copyOf(expected));
   }
 
   private void assertReady(int expectedReady) throws Exception {
     Set<Integer> allVersions = ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
     GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    assertThat(
+    assertWithMessage("ready state for index versions")
+        .that(
             allVersions.stream().collect(toImmutableMap(v -> v, v -> status.getReady(CHANGES, v))))
-        .named("ready state for index versions")
         .isEqualTo(allVersions.stream().collect(toImmutableMap(v -> v, v -> v == expectedReady)));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index a573e35..e48088e 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndexCollection;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import java.util.Optional;
@@ -43,9 +43,9 @@
       QueryOptions opts =
           QueryOptions.create(IndexConfig.createDefault(), 0, 1, ImmutableSet.of("name"));
       Optional<ProjectData> allProjectsData = projectIndex.getSearchIndex().get(allProjects, opts);
-      assertThat(allProjectsData.isPresent()).isTrue();
+      assertThat(allProjectsData).isPresent();
       Optional<ProjectData> allUsersData = projectIndex.getSearchIndex().get(allUsers, opts);
-      assertThat(allUsersData.isPresent()).isTrue();
+      assertThat(allUsersData).isPresent();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index b30dc41..52de5ad 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -16,215 +16,320 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.apache.http.HttpStatus.SC_CREATED;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_OK;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Expect;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.apache.http.message.BasicHeader;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
+/**
+ * This test tests the tracing of requests.
+ *
+ * <p>To verify that tracing is working we do:
+ *
+ * <ul>
+ *   <li>Register a plugin extension that we know is invoked when the request is done. Within the
+ *       implementation of this plugin extension we access the status of the thread local state in
+ *       the {@link LoggingContext} and store it locally in the plugin extension class.
+ *   <li>Do a request (e.g. REST) that triggers the plugin extension.
+ *   <li>When the plugin extension is invoked it records the current logging context.
+ *   <li>After the request is done the test verifies that logging context that was recorded by the
+ *       plugin extension has the expected state.
+ * </ul>
+ */
 public class TraceIT extends AbstractDaemonTest {
   @Rule public final Expect expect = Expect.create();
 
-  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
-  @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+  @Inject private ExtensionRegistry extensionRegistry;
   @Inject private WorkQueue workQueue;
 
-  private TraceValidatingProjectCreationValidationListener projectCreationListener;
-  private RegistrationHandle projectCreationListenerRegistrationHandle;
-  private TraceValidatingCommitValidationListener commitValidationListener;
-  private RegistrationHandle commitValidationRegistrationHandle;
-
-  @Before
-  public void setup() {
-    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
-    projectCreationListenerRegistrationHandle =
-        projectCreationValidationListeners.add("gerrit", projectCreationListener);
-    commitValidationListener = new TraceValidatingCommitValidationListener();
-    commitValidationRegistrationHandle =
-        commitValidationListeners.add("gerrit", commitValidationListener);
-  }
-
-  @After
-  public void cleanup() {
-    projectCreationListenerRegistrationHandle.remove();
-    commitValidationRegistrationHandle.remove();
-  }
-
   @Test
   public void restCallWithoutTrace() throws Exception {
-    RestResponse response = adminRestSession.put("/projects/new1");
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-    assertThat(projectCreationListener.traceId).isNull();
-    assertThat(projectCreationListener.isLoggingForced).isFalse();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new1");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new1");
+    }
+  }
+
+  @Test
+  public void restCallForChangeSetsProjectTag() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    TraceChangeIndexedListener changeIndexedListener = new TraceChangeIndexedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedListener)) {
+      RestResponse response =
+          adminRestSession.post(
+              "/changes/" + changeId + "/revisions/current/review", ReviewInput.approve());
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(changeIndexedListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void restCallWithTraceRequestParam() throws Exception {
-    RestResponse response =
-        adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
-    assertThat(projectCreationListener.traceId).isNotNull();
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response =
+          adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+      assertThat(projectCreationListener.traceId).isNotNull();
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new2");
+    }
   }
 
   @Test
   public void restCallWithTraceRequestParamAndProvidedTraceId() throws Exception {
-    RestResponse response =
-        adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=issue/123");
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response =
+          adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=issue/123");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new3");
+    }
   }
 
   @Test
   public void restCallWithTraceHeader() throws Exception {
-    RestResponse response =
-        adminRestSession.putWithHeader(
-            "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
-    assertThat(projectCreationListener.traceId).isNotNull();
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response =
+          adminRestSession.putWithHeader(
+              "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+      assertThat(projectCreationListener.traceId).isNotNull();
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new4");
+    }
   }
 
   @Test
   public void restCallWithTraceHeaderAndProvidedTraceId() throws Exception {
-    RestResponse response =
-        adminRestSession.putWithHeader(
-            "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response =
+          adminRestSession.putWithHeader(
+              "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new5");
+    }
   }
 
   @Test
   public void restCallWithTraceRequestParamAndTraceHeader() throws Exception {
-    // trace ID only specified by trace header
-    RestResponse response =
-        adminRestSession.putWithHeader(
-            "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      // trace ID only specified by trace header
+      RestResponse response =
+          adminRestSession.putWithHeader(
+              "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new6");
 
-    // trace ID only specified by trace request parameter
-    response =
-        adminRestSession.putWithHeader(
-            "/projects/new7?trace=issue/123", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+      // trace ID only specified by trace request parameter
+      response =
+          adminRestSession.putWithHeader(
+              "/projects/new7?trace=issue/123",
+              new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new7");
 
-    // same trace ID specified by trace header and trace request parameter
-    response =
-        adminRestSession.putWithHeader(
-            "/projects/new8?trace=issue/123",
-            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+      // same trace ID specified by trace header and trace request parameter
+      response =
+          adminRestSession.putWithHeader(
+              "/projects/new8?trace=issue/123",
+              new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new8");
 
-    // different trace IDs specified by trace header and trace request parameter
-    response =
-        adminRestSession.putWithHeader(
-            "/projects/new9?trace=issue/123",
-            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeaders(RestApiServlet.X_GERRIT_TRACE))
-        .containsExactly("issue/123", "issue/456");
-    assertThat(projectCreationListener.traceIds).containsExactly("issue/123", "issue/456");
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+      // different trace IDs specified by trace header and trace request parameter
+      response =
+          adminRestSession.putWithHeader(
+              "/projects/new9?trace=issue/123",
+              new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeaders(RestApiServlet.X_GERRIT_TRACE))
+          .containsExactly("issue/123", "issue/456");
+      assertThat(projectCreationListener.traceIds).containsExactly("issue/123", "issue/456");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new9");
+    }
   }
 
   @Test
   public void pushWithoutTrace() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/heads/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isNull();
-    assertThat(commitValidationListener.isLoggingForced).isFalse();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isNull();
+      assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void pushWithTrace() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(ImmutableList.of("trace"));
-    PushOneCommit.Result r = push.to("refs/heads/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isNotNull();
-    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(ImmutableList.of("trace"));
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isNotNull();
+      assertThat(commitValidationListener.isLoggingForced).isTrue();
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void pushWithTraceAndProvidedTraceId() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(ImmutableList.of("trace=issue/123"));
-    PushOneCommit.Result r = push.to("refs/heads/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
-    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(ImmutableList.of("trace=issue/123"));
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+      assertThat(commitValidationListener.isLoggingForced).isTrue();
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void pushForReviewWithoutTrace() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isNull();
-    assertThat(commitValidationListener.isLoggingForced).isFalse();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isNull();
+      assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void pushForReviewWithTrace() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(ImmutableList.of("trace"));
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isNotNull();
-    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(ImmutableList.of("trace"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isNotNull();
+      assertThat(commitValidationListener.isLoggingForced).isTrue();
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
   public void pushForReviewWithTraceAndProvidedTraceId() throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(ImmutableList.of("trace=issue/123"));
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
-    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(ImmutableList.of("trace=issue/123"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+      assertThat(commitValidationListener.isLoggingForced).isTrue();
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
   }
 
   @Test
@@ -263,6 +368,409 @@
     assertForceLogging(false);
   }
 
+  @Test
+  public void performanceLoggingForRestCall() throws Exception {
+    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testPerformanceLogger)) {
+      RestResponse response = adminRestSession.put("/projects/new10");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+
+      // This assertion assumes that the server invokes the PerformanceLogger plugins before it
+      // sends
+      // the response to the client. If this assertion gets flaky it's likely that this got changed
+      // on
+      // server-side.
+      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void performanceLoggingForPush() throws Exception {
+    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testPerformanceLogger)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertOkStatus();
+      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "false")
+  public void noPerformanceLoggingIfDisabled() throws Exception {
+    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testPerformanceLogger)) {
+      RestResponse response = adminRestSession.put("/projects/new11");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertOkStatus();
+
+      assertThat(testPerformanceLogger.logEntries()).isEmpty();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new12")
+  public void traceProject() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new12");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new12");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectMatchRegEx() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new13");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new13");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "][")
+  public void traceProjectInvalidRegEx() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new14");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new14");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  public void traceAccount() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new15");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new15");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  public void traceAccountNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new16");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new16");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "999")
+  public void traceAccountNotFound() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new17");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new17");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "invalid")
+  public void traceAccountInvalidId() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new18");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new18");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "REST")
+  public void traceRequestType() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new19");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new19");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "SSH")
+  public void traceRequestTypeNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new20");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new20");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
+  public void traceProjectInvalidRequestType() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new21");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new21");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccount() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new22");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new22");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectForAccountNoProjectMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new23");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccountNoAccountMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new24");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  public void traceRequestUri() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new23");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*/foo")
+  public void traceRequestUriNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new23");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "][")
+  public void traceRequestUriInvalidRegEx() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new24");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+  public void autoRetryWithTrace() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failAlways = true;
+    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.isLoggingForced).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failAlways = true;
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(traceSubmitRule)
+            .add(
+                new ExceptionHook() {
+                  @Override
+                  public boolean shouldRetry(Throwable t) {
+                    return true;
+                  }
+                })) {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).isNull();
+      assertThat(traceSubmitRule.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  public void noAutoRetryWithTraceIfDisabled() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).isNull();
+      assertThat(traceSubmitRule.isLoggingForced).isFalse();
+    }
+  }
+
   private void assertForceLogging(boolean expected) {
     assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
         .isEqualTo(expected);
@@ -273,6 +781,7 @@
     String traceId;
     ImmutableSet<String> traceIds;
     Boolean isLoggingForced;
+    ImmutableSetMultimap<String, String> tags;
 
     @Override
     public void validateNewProject(CreateProjectArgs args) throws ValidationException {
@@ -280,12 +789,14 @@
           Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
       this.traceIds = LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID");
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
     }
   }
 
   private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
     String traceId;
     Boolean isLoggingForced;
+    ImmutableSetMultimap<String, String> tags;
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
@@ -293,7 +804,67 @@
       this.traceId =
           Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
       return ImmutableList.of();
     }
   }
+
+  private static class TraceChangeIndexedListener implements ChangeIndexedListener {
+    ImmutableSetMultimap<String, String> tags;
+
+    @Override
+    public void onChangeIndexed(String projectName, int id) {
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
+    }
+
+    @Override
+    public void onChangeDeleted(int id) {}
+  }
+
+  private static class TraceSubmitRule implements SubmitRule {
+    String traceId;
+    Boolean isLoggingForced;
+    boolean failOnce;
+    boolean failAlways;
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+
+      if (failOnce || failAlways) {
+        failOnce = false;
+        throw new IllegalStateException("forced failure from test");
+      }
+
+      SubmitRecord submitRecord = new SubmitRecord();
+      submitRecord.status = SubmitRecord.Status.OK;
+      return Optional.of(submitRecord);
+    }
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_TraceIT_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 5e652c0..03202f2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -18,21 +18,33 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import java.util.List;
 
 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);
+  /**
+   * Asserts an AccountInfo for an active account.
+   *
+   * @param testAccount the TestAccount which the provided AccountInfo is expected to match
+   * @param accountInfo the AccountInfo that should be asserted
+   */
+  public static void assertAccountInfo(TestAccount testAccount, AccountInfo accountInfo) {
+    assertThat(accountInfo._accountId).isEqualTo(testAccount.id().get());
+    assertThat(accountInfo.name).isEqualTo(testAccount.fullName());
+    assertThat(accountInfo.email).isEqualTo(testAccount.email());
+    assertThat(accountInfo.inactive).isNull();
   }
 
+  /**
+   * Asserts an AccountInfos for active accounts.
+   *
+   * @param expected the TestAccounts which the provided AccountInfos are expected to match
+   * @param actual the AccountInfos that should be asserted
+   */
   public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
     Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
-    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> new Account.Id(a._accountId));
+    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> Account.id(a._accountId));
     assertThat(actualIds).containsExactlyElementsIn(expectedIds).inOrder();
     for (int i = 0; i < expected.size(); i++) {
       AccountAssert.assertAccountInfo(expected.get(i), actual.get(i));
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/BUILD b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
index 66ea6f3..a00a8b2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
@@ -5,7 +5,10 @@
     srcs = glob(["*IT.java"]),
     group = "rest_account",
     labels = ["rest"],
-    deps = [":util"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/account/externalids/testing",
+    ],
 )
 
 java_library(
@@ -18,7 +21,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//lib:junit",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index e7ce43f..be4fde0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
@@ -26,24 +29,30 @@
 import static com.google.gerrit.common.data.GlobalCapability.RUN_AS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.collect.Iterables;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void capabilitiesUser() throws Exception {
-    Iterable<String> all =
-        Iterables.filter(
-            GlobalCapability.getAllNames(),
-            c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c));
-
-    allowGlobalCapabilities(REGISTERED_USERS, all);
+    ImmutableList<String> all =
+        GlobalCapability.getAllNames().stream()
+            .filter(c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c))
+            .collect(toImmutableList());
+    TestProjectUpdate.Builder allowBuilder = projectOperations.allProjectsForUpdate();
+    all.forEach(c -> allowBuilder.add(allowCapability(c).group(REGISTERED_USERS)));
+    allowBuilder.update();
     try {
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
@@ -67,7 +76,9 @@
         }
       }
     } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, all);
+      TestProjectUpdate.Builder removeBuilder = projectOperations.allProjectsForUpdate();
+      all.forEach(c -> removeBuilder.remove(capabilityKey(c).group(REGISTERED_USERS)));
+      removeBuilder.update();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index 84f218d..aaeed02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableMultimap;
@@ -24,13 +25,13 @@
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -149,16 +150,20 @@
   @Test
   public void setPreferredEmailToNonExistingEmail() throws Exception {
     String email = "non-existing@example.com";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + email);
-    gApi.accounts().self().email(email).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(email).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + email);
   }
 
   @Test
   public void setPreferredEmailToEmailOfOtherAccount() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + user.email());
-    gApi.accounts().self().email(user.email()).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(user.email()).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + user.email());
   }
 
   @Test
@@ -201,9 +206,11 @@
     Context oldCtx =
         createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), user.email()));
     try {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("email in use by another account");
-      gApi.accounts().self().email(user.email()).setPreferred();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().self().email(user.email()).setPreferred());
+      assertThat(thrown).hasMessageThat().contains("email in use by another account");
     } finally {
       atrScope.set(oldCtx);
     }
@@ -248,9 +255,7 @@
 
     // Now the email is no longer found
     requestScopeOperations.resetCurrentApiUser();
-    emailApi = gApi.accounts().self().email(email);
-    exception.expect(ResourceNotFoundException.class);
-    emailApi.get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().self().email(email).get());
   }
 
   private Set<String> getEmails() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index f08fa44..6e16435 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,16 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithEmptyNote;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithInvalidConfig;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithKeyThatDoesntMatchNoteId;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithoutAccountId;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -33,9 +38,12 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -44,8 +52,6 @@
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -54,6 +60,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -70,13 +77,8 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -91,11 +93,26 @@
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
+  @ConfigSuite.Default
+  public static Config partialCacheReloadingEnabled() {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", true);
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config partialCacheReloadingDisabled() {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", false);
+    return cfg;
+  }
+
   @Test
   public void getExternalIds() throws Exception {
-    Collection<ExternalId> expectedIds = getAccountState(user.id()).getExternalIds();
+    Collection<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
@@ -112,16 +129,20 @@
   @Test
   public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts().id(admin.id().get()).getExternalIds();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.accounts().id(admin.id().get()).getExternalIds());
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
   public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
-    Collection<ExternalId> expectedIds = getAccountState(admin.id()).getExternalIds();
+    Collection<ExternalId> expectedIds = getAccountState(admin.id()).externalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/" + admin.id() + "/external.ids");
@@ -165,27 +186,38 @@
   public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts()
-        .id(admin.id().get())
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
   public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
-    gApi.accounts()
-        .self()
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("External id %s does not exist", extIds.get(0).identity));
   }
 
   @Test
   public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -248,29 +280,33 @@
 
   @Test
   public void fetchExternalIdsBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+    final TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
 
     // refs/meta/external-ids is only visible to users with the 'Access Database' capability
-    try {
-      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-      fail("expected TransportException");
-    } catch (TransportException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
-    }
+    TransportException thrown =
+        assertThrows(
+            TransportException.class, () -> fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     // re-clone to get new request context, otherwise the old global capabilities are still cached
     // in the IdentifiedUser object
-    allUsersRepo = cloneProject(allUsers, user);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    TestRepository<InMemoryRepository> allUsersRepo2 = cloneProject(allUsers, user);
+    fetch(allUsersRepo2, RefNames.REFS_EXTERNAL_IDS);
   }
 
   @Test
   public void pushToExternalIdsBranch() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -295,14 +331,21 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithoutAccountId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(),
+        allUsersRepo.getRevWalk(),
+        admin.newIdent(),
+        admin.id(),
+        "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -313,14 +356,21 @@
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithKeyThatDoesntMatchNoteId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(),
+        allUsersRepo.getRevWalk(),
+        admin.newIdent(),
+        admin.id(),
+        "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -330,14 +380,17 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithInvalidConfig(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), admin.newIdent(), "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -347,14 +400,17 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithEmptyNote(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), admin.newIdent(), "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -387,7 +443,10 @@
 
   private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -403,7 +462,10 @@
 
   @Test
   public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
@@ -424,7 +486,10 @@
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
@@ -446,9 +511,11 @@
 
   @Test
   public void checkConsistencyNotAllowed() throws Exception {
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.config().server().checkConsistency(new ConsistencyCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   private ConsistencyProblemInfo consistencyError(String message) {
@@ -525,7 +592,8 @@
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       String externalId = nextId(scheme, i);
-      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      String noteId =
+          insertExternalIdWithoutAccountId(repo, rw, admin.newIdent(), admin.id(), externalId);
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -535,7 +603,9 @@
                   + ".accountId' is missing, expected account ID"));
 
       externalId = nextId(scheme, i);
-      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      noteId =
+          insertExternalIdWithKeyThatDoesntMatchNoteId(
+              repo, rw, admin.newIdent(), admin.id(), externalId);
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -546,12 +616,12 @@
                   + noteId
                   + "'"));
 
-      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, admin.newIdent(), nextId(scheme, i));
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
 
-      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      noteId = insertExternalIdWithEmptyNote(repo, rw, admin.newIdent(), nextId(scheme, i));
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -570,125 +640,8 @@
         "password");
   }
 
-  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id());
-          ObjectId noteId = extId.key().sha1();
-          Config c = new Config();
-          extId.writeToConfig(c);
-          c.unset("externalId", extId.key().get(), "accountId");
-          byte[] raw = c.toText().getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
-      Repository repo, RevWalk rw, String externalId) throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id());
-          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
-          Config c = new Config();
-          extId.writeToConfig(c);
-          byte[] raw = c.toText().getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-          byte[] raw = "bad-config".getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-          byte[] raw = "".getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalId(Repository repo, RevWalk rw, ExternalIdInserter extIdInserter)
-      throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
-
-      CommitBuilder cb = new CommitBuilder();
-      cb.setMessage("Update external IDs");
-      cb.setTreeId(noteMap.writeTree(ins));
-      cb.setAuthor(admin.newIdent());
-      cb.setCommitter(admin.newIdent());
-      if (!rev.equals(ObjectId.zeroId())) {
-        cb.setParentId(rev);
-      } else {
-        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
-      }
-      if (cb.getTreeId() == null) {
-        if (rev.equals(ObjectId.zeroId())) {
-          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
-        } else {
-          RevCommit p = rw.parseCommit(rev);
-          cb.setTreeId(p.getTree()); // Copy tree from parent.
-        }
-      }
-      ObjectId commitId = ins.insert(cb);
-      ins.flush();
-
-      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
-      u.setExpectedOldObjectId(rev);
-      u.setNewObjectId(commitId);
-      RefUpdate.Result res = u.update();
-      switch (res) {
-        case NEW:
-        case FAST_FORWARD:
-        case NO_CHANGE:
-        case RENAMED:
-        case FORCED:
-          break;
-        case LOCK_FAILURE:
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new IOException("Updating external IDs failed with " + res);
-      }
-      return noteId.getName();
-    }
-  }
-
   private ExternalId createExternalIdForNonExistingAccount(String externalId) {
-    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+    return ExternalId.create(ExternalId.Key.parse(externalId), Account.id(1));
   }
 
   private ExternalId createExternalIdWithInvalidEmail(String externalId) {
@@ -715,7 +668,7 @@
   @Test
   public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
     ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
-    Account.Id accountId = new Account.Id(1024 * 100);
+    Account.Id accountId = Account.id(1024 * 100);
     accountsUpdateProvider
         .get()
         .insert(
@@ -757,23 +710,25 @@
 
   @Test
   public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    assume().that(isPartialCacheReloadingEnabled()).isFalse();
+
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byAccount(admin.id());
+      assertThrows(IOException.class, () -> externalIds.byAccount(admin.id()));
     }
   }
 
   @Test
   public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    assume().that(isPartialCacheReloadingEnabled()).isFalse();
+
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byEmail(admin.email());
+      assertThrows(IOException.class, () -> externalIds.byEmail(admin.email()));
     }
   }
 
@@ -910,6 +865,10 @@
     }
   }
 
+  private boolean isPartialCacheReloadingEnabled() {
+    return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
+  }
+
   private void insertExtId(ExternalId extId) throws Exception {
     accountsUpdateProvider
         .get()
@@ -976,9 +935,13 @@
     return info;
   }
 
-  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+  private void allowPushOfExternalIds() {
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .update();
   }
 
   private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
@@ -990,9 +953,4 @@
     externalIdReader.setFailOnLoad(true);
     return () -> externalIdReader.setFailOnLoad(false);
   }
-
-  @FunctionalInterface
-  private interface ExternalIdInserter {
-    public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index 27ae8b12..9202f42 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -19,8 +19,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
@@ -30,6 +30,6 @@
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
     Account account = getAccount(admin.id());
-    assertThat(info.registeredOn).isEqualTo(account.getRegisteredOn());
+    assertThat(info.registeredOn).isEqualTo(account.registeredOn());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 11f7c0f..782638a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -14,19 +14,26 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class GetAccountIT extends AbstractDaemonTest {
-  @Test(expected = ResourceNotFoundException.class)
+  @Inject private AccountOperations accountOperations;
+
+  @Test
   public void getNonExistingAccount_NotFound() throws Exception {
-    gApi.accounts().id("non-existing").get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("non-existing").get());
   }
 
   @Test
@@ -50,6 +57,16 @@
     testGetAccount("self", admin);
   }
 
+  @Test
+  public void getInactiveAccount() throws Exception {
+    accountOperations.account(user.id()).forUpdate().inactive().update();
+    AccountInfo accountInfo = gApi.accounts().id(user.id().get()).get();
+    assertThat(accountInfo._accountId).isEqualTo(user.id().get());
+    assertThat(accountInfo.name).isEqualTo(user.fullName());
+    assertThat(accountInfo.email).isEqualTo(user.email());
+    assertThat(accountInfo.inactive).isTrue();
+  }
+
   private void testGetAccount(String id, TestAccount expectedAccount) throws Exception {
     assertAccountInfo(expectedAccount, gApi.accounts().id(id).get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4dec505..faaba06 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -15,9 +15,15 @@
 package com.google.gerrit.acceptance.rest.account;
 
 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.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -28,10 +34,17 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -49,17 +62,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.apache.http.Header;
@@ -73,6 +80,7 @@
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private ChangeMessagesUtil cmUtil;
   @Inject private CommentsUtil commentsUtil;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private RestSession anonRestSession;
@@ -106,11 +114,11 @@
     revision.review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id());
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
@@ -129,9 +137,10 @@
     in.onBehalfOf = user.id().toString();
     in.message = "Message on behalf of";
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("label required to post review on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("label required to post review on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -143,9 +152,10 @@
     ReviewInput in = new ReviewInput().label("Not-A-Label", 5);
     in.onBehalfOf = user.id().toString();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Not-A-Label\" is not a configured label");
   }
 
   @Test
@@ -163,7 +173,7 @@
   @Test
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified = Util.verified();
+      LabelType verified = TestLabels.verified();
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.save();
     }
@@ -175,10 +185,11 @@
     in.onBehalfOf = user.id().toString();
     in.label("Verified", 1);
 
-    exception.expect(AuthException.class);
-    exception.expectMessage(
-        "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -207,11 +218,11 @@
     gApi.changes().id(r.getChangeId()).current().review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id());
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
     Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
@@ -266,9 +277,10 @@
     in.label("Code-Review", 1);
     in.drafts = DraftHandling.PUBLISH;
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to modify other user's drafts");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("not allowed to modify other user's drafts");
   }
 
   @Test
@@ -281,10 +293,10 @@
     in.onBehalfOf = "doesnotexist";
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -299,9 +311,11 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id() + " cannot see change");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
@@ -318,10 +332,10 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
@@ -338,8 +352,8 @@
     assertThat(cd.change().isMerged()).isTrue();
     PatchSetApproval submitter =
         approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.getAccountId()).isEqualTo(admin2.id());
-    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id());
+    assertThat(submitter.accountId()).isEqualTo(admin2.id());
+    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
   }
 
   @Test
@@ -350,10 +364,12 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -365,9 +381,15 @@
         .review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email();
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of other users not permitted");
-    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(project.get() + "~master~" + r.getChangeId())
+                    .current()
+                    .submit(in));
+    assertThat(thrown).hasMessageThat().contains("submit on behalf of other users not permitted");
   }
 
   @Test
@@ -380,9 +402,13 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id() + " cannot see change");
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
@@ -397,10 +423,12 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
@@ -510,11 +538,11 @@
     adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in).assertOK();
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id());
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id()); // not user2
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
@@ -546,54 +574,53 @@
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReviewType = Util.codeReview();
-      String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), forCodeReviewAs, -1, 1, uuid, heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.SUBMIT_AS, uuid, heads);
-      Util.allow(u.getConfig(), Permission.SUBMIT, uuid, heads);
-      LabelType codeReviewType = Util.codeReview();
-      Util.allow(u.getConfig(), Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
-      u.save();
-    }
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT_AS).ref(heads).group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(heads).group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
   }
 
   private void blockRead(GroupInfo group) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(
-          u.getConfig(), Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(AccountGroup.uuid(group.id)))
+        .update();
   }
 
   private void allowRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.allow(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void removeRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.remove(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private static Header runAsHeader(Object user) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index d0c1fa4..2c9107c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -114,9 +115,11 @@
     pwi.notifyNewPatchSets = true;
     projectsToWatch.add(pwi);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("duplicate entry for project " + projectName);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(thrown).hasMessageThat().contains("duplicate entry for project " + projectName);
   }
 
   @Test
@@ -146,9 +149,9 @@
     pwi.notifyNewChanges = true;
     pwi.notifyAllComments = true;
     projectsToWatch.add(pwi);
-
-    exception.expect(UnprocessableEntityException.class);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
new file mode 100644
index 0000000..b6ef5a3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.auth;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import org.junit.Test;
+
+public class AuthenticationCheckIT extends AbstractDaemonTest {
+  @Test
+  public void authCheck_loggedInUser_returnsOk() throws Exception {
+    RestResponse r = adminRestSession.get("/auth-check");
+    r.assertNoContent();
+  }
+
+  @Test
+  public void authCheck_anonymousUser_returnsForbidden() throws Exception {
+    RestSession anonymous = new RestSession(server, null);
+    RestResponse r = anonymous.get("/auth-check");
+    r.assertForbidden();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/BUILD b/javatests/com/google/gerrit/acceptance/rest/auth/BUILD
new file mode 100644
index 0000000..5de1607
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "auth",
+    labels = ["rest"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 55744cc..8a284d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,7 +36,6 @@
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.reviewdb.client.Patch;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 5f210cc..00dcb4f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.binding;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -22,10 +23,12 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import java.util.List;
 import java.util.Optional;
 import org.junit.Test;
@@ -81,10 +84,15 @@
           // Task deletion must be tested last
           RestCall.delete("/config/server/tasks/%s"));
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void configEndpoints() throws Exception {
     // 'Access Database' is needed for the '/config/server/check.consistency' REST endpoint
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     RestApiCallHelper.execute(adminRestSession, CONFIG_ENDPOINTS);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
index 27df565..22feeb7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
@@ -31,7 +33,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -107,7 +108,8 @@
 
     @Override
     public RestView<RevisionResource> list() throws RestApiException {
-      return (RestReadView<RevisionResource>) resource -> ImmutableList.of("one", "two");
+      return (RestReadView<RevisionResource>)
+          resource -> Response.ok(ImmutableList.of("one", "two"));
     }
 
     @Override
@@ -125,24 +127,24 @@
   static class TestPostOnCollection
       implements RestCollectionModifyView<RevisionResource, TestPluginResource, String> {
     @Override
-    public Object apply(RevisionResource parentResource, String input) throws Exception {
-      return "test";
+    public Response<String> apply(RevisionResource parentResource, String input) throws Exception {
+      return Response.ok("test");
     }
   }
 
   @Singleton
   static class TestPost implements RestModifyView<TestPluginResource, String> {
     @Override
-    public String apply(TestPluginResource resource, String input) throws Exception {
-      return "test";
+    public Response<String> apply(TestPluginResource resource, String input) throws Exception {
+      return Response.ok("test");
     }
   }
 
   @Singleton
   static class TestGet implements RestReadView<TestPluginResource> {
     @Override
-    public String apply(TestPluginResource resource) throws Exception {
-      return "test";
+    public Response<String> apply(TestPluginResource resource) throws Exception {
+      return Response.ok("test");
     }
   }
 
@@ -153,8 +155,8 @@
       RestApiCallHelper.execute(
           adminRestSession,
           TEST_CALLS.asList(),
-          String.valueOf(patchSetId.changeId.id),
-          String.valueOf(patchSetId.patchSetId));
+          String.valueOf(patchSetId.changeId().get()),
+          String.valueOf(patchSetId.get()));
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index 178a326..b447534 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
@@ -157,7 +158,8 @@
 
     @Override
     public RestView<TopLevelResource> list() throws RestApiException {
-      return (RestReadView<TopLevelResource>) resource -> ImmutableList.of("one", "two");
+      return (RestReadView<TopLevelResource>)
+          resource -> Response.ok(ImmutableList.of("one", "two"));
     }
 
     @Override
@@ -175,24 +177,24 @@
   static class TestPostOnCollection
       implements RestCollectionModifyView<TopLevelResource, TestPluginResource, String> {
     @Override
-    public Object apply(TopLevelResource parentResource, String input) throws Exception {
-      return "test";
+    public Response<String> apply(TopLevelResource parentResource, String input) throws Exception {
+      return Response.ok("test");
     }
   }
 
   @Singleton
   static class TestPost implements RestModifyView<TestPluginResource, String> {
     @Override
-    public String apply(TestPluginResource resource, String input) throws Exception {
-      return "test";
+    public Response<String> apply(TestPluginResource resource, String input) throws Exception {
+      return Response.ok("test");
     }
   }
 
   @Singleton
   static class TestGet implements RestReadView<TestPluginResource> {
     @Override
-    public String apply(TestPluginResource resource) throws Exception {
-      return "test";
+    public Response<String> apply(TestPluginResource resource) throws Exception {
+      return Response.ok("test");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index ed09ddd..f48a603 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -17,7 +17,8 @@
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.entities.RefNames.REFS_DASHBOARDS;
 import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
 import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
 import static org.apache.http.HttpStatus.SC_NOT_FOUND;
@@ -29,10 +30,10 @@
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -216,7 +217,7 @@
         testRepo
             .commit()
             .message("A change")
-            .parent(getRemoteHead())
+            .parent(projectOperations.project(project).getHead("master"))
             .add(filename, "content")
             .insertChangeId()
             .create();
@@ -234,7 +235,11 @@
 
   private void createDefaultDashboard() throws Exception {
     String dashboardRef = REFS_DASHBOARDS + "team";
-    grant(project, "refs/meta/*", Permission.CREATE);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(project.get()).branch(dashboardRef).create(new BranchInput());
 
     try (Repository r = repoManager.openRepository(project);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index a299b9a..b822750 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -16,15 +16,25 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+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.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -32,14 +42,27 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -52,27 +75,18 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -80,7 +94,7 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -103,11 +117,11 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.RefSpec;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
+@UseClockStep
+@UseTimezone(timezone = "US/Eastern")
 public abstract class AbstractSubmit extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config submitWholeTopicEnabled() {
@@ -115,57 +129,36 @@
   }
 
   @Inject private ApprovalsUtil approvalsUtil;
-  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Submit submitHandler;
-
-  private RegistrationHandle onSubmitValidatorHandle;
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @After
-  public void removeOnSubmitValidator() {
-    if (onSubmitValidatorHandle != null) {
-      onSubmitValidatorHandle.remove();
-    }
-  }
+  @Inject private ExtensionRegistry extensionRegistry;
 
   protected abstract SubmitType getSubmitType();
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitToEmptyRepo() throws Exception {
+  public void submitToEmptyRepo() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
   @Test
-  public void submitSingleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitSingleChange() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -183,13 +176,13 @@
   }
 
   @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
@@ -223,7 +216,7 @@
           break;
         case REBASE_IF_NECESSARY:
         case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
+          String change2hash = change2.getChange().currentPatchSet().commitId().name();
           assertThat(e.getMessage())
               .isEqualTo(
                   "Cannot rebase "
@@ -255,11 +248,11 @@
           break;
         case CHERRY_PICK:
         default:
-          fail("Should not reach here.");
+          assertWithMessage("Should not reach here.").fail();
           break;
       }
 
-      RevCommit headAfterSubmit = getRemoteHead();
+      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
       assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
       assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
       assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
@@ -267,19 +260,19 @@
   }
 
   @Test
-  public void submitMultipleChangesPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesPreview() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
     PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
     // change 2 is not approved, but we ignore labels
     approve(change3.getChangeId());
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
     Map<String, Map<String, Integer>> expected = new HashMap<>();
     expected.put(project.get(), new HashMap<>());
     expected.get(project.get()).put("refs/heads/master", 3);
 
-    assertThat(actual).containsKey(new Branch.NameKey(project, "refs/heads/master"));
+    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       // CherryPick ignores dependencies, thus only change and destination
       // branch refs are modified.
@@ -293,7 +286,7 @@
     }
 
     // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -305,10 +298,14 @@
   }
 
   @Test
-  public void submitNoPermission() throws Exception {
+  public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
     Project.NameKey p = projectOperations.newProject().create();
-    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -319,16 +316,16 @@
   }
 
   @Test
-  public void noSelfSubmit() throws Exception {
+  public void noSelfSubmit() throws Throwable {
     // create project where submit is blocked for the change owner
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -345,16 +342,16 @@
   }
 
   @Test
-  public void onlySelfSubmit() throws Exception {
+  public void onlySelfSubmit() throws Throwable {
     // create project where only the change owner can submit
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -372,7 +369,7 @@
   }
 
   @Test
-  public void submitWholeTopicMultipleProjects() throws Exception {
+  public void submitWholeTopicMultipleProjects() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -408,7 +405,7 @@
   }
 
   @Test
-  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
+  public void submitWholeTopicMultipleBranchesOnSameProject() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -416,7 +413,7 @@
     Project.NameKey keyA = createProjectForPush(getSubmitType());
     TestRepository<?> repoA = cloneProject(keyA);
 
-    RevCommit initialHead = getRemoteHead(keyA, "master");
+    RevCommit initialHead = projectOperations.project(keyA).getHead("master");
 
     // Create the dev branch on the test project
     BranchInput in = new BranchInput();
@@ -450,7 +447,7 @@
   }
 
   @Test
-  public void submitWholeTopic() throws Exception {
+  public void submitWholeTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
@@ -488,7 +485,82 @@
   }
 
   @Test
-  public void submitReusingOldTopic() throws Exception {
+  public void submitWholeTopicWithMultipleTopics() throws Throwable {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic1 = "test-topic-1";
+    String topic2 = "test-topic-2";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic1);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic1);
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic2);
+    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "content", topic2);
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+    String expectedTopic1 = name(topic1);
+    String expectedTopic2 = name(topic2);
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      change1.assertChange(Change.Status.NEW, expectedTopic1, admin);
+      change2.assertChange(Change.Status.NEW, expectedTopic1, admin);
+
+    } else {
+      change1.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+      change2.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+    }
+
+    // Check for the exact change to have the correct submitter.
+    assertSubmitter(change4);
+    // Also check submitters for changes submitted via the topic relationship.
+    assertSubmitter(change3);
+    if (getSubmitType() != SubmitType.CHERRY_PICK) {
+      assertSubmitter(change1);
+      assertSubmitter(change2);
+    }
+
+    // Check that the repo has the expected commits
+    List<RevCommit> log = getRemoteLog();
+    List<String> commitsInRepo = log.stream().map(RevCommit::getShortMessage).collect(toList());
+    int expectedCommitCount;
+    switch (getSubmitType()) {
+      case MERGE_ALWAYS:
+        // initial commit + 4 commits + merge commit
+        expectedCommitCount = 6;
+        break;
+      case CHERRY_PICK:
+        // initial commit + 2 commits
+        expectedCommitCount = 3;
+        break;
+      case FAST_FORWARD_ONLY:
+      case INHERIT:
+      case MERGE_IF_NECESSARY:
+      case REBASE_ALWAYS:
+      case REBASE_IF_NECESSARY:
+      default:
+        // initial commit + 4 commits
+        expectedCommitCount = 5;
+        break;
+    }
+    assertThat(log).hasSize(expectedCommitCount);
+
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      assertThat(commitsInRepo).containsAtLeast("Initial empty repository", "Change 3", "Change 4");
+      assertThat(commitsInRepo).doesNotContain("Change 1");
+      assertThat(commitsInRepo).doesNotContain("Change 2");
+    } else if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
+      assertThat(commitsInRepo)
+          .contains(
+              String.format(
+                  "Merge changes from topics \"%s\", \"%s\"", expectedTopic1, expectedTopic2));
+    } else {
+      assertThat(commitsInRepo)
+          .containsAtLeast(
+              "Initial empty repository", "Change 1", "Change 2", "Change 3", "Change 4");
+    }
+  }
+
+  @Test
+  public void submitReusingOldTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
@@ -519,13 +591,13 @@
   }
 
   private void assertSubmittedTogether(String changeId, Iterable<String> expected)
-      throws Exception {
+      throws Throwable {
     assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
         .containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void submitWorkInProgressChange() throws Exception {
+  public void submitWorkInProgressChange() throws Throwable {
     PushOneCommit.Result change = pushTo("refs/for/master%wip");
     Change.Id num = change.getChange().getId();
     submitWithConflict(
@@ -539,15 +611,19 @@
   }
 
   @Test
-  public void submitWithHiddenBranchInSameTopic() throws Exception {
+  public void submitWithHiddenBranchInSameTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
     Change.Id num = visible.getChange().getId();
 
-    createBranch(new Branch.NameKey(project, "hidden"));
+    createBranch(BranchNameKey.create(project, "hidden"));
     PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
     approve(hidden.getChangeId());
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
 
     submit(
         visible.getChangeId(),
@@ -557,7 +633,7 @@
   }
 
   @Test
-  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
+  public void submitChangeWhenParentOfOtherBranchTip() throws Throwable {
     // Chain of two commits
     // Push both to topic-branch
     // Push the first commit for review and submit
@@ -588,7 +664,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchTip() throws Exception {
+  public void submitMergeOfNonChangeBranchTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -598,7 +674,7 @@
     // | /
     // I   -- master
     //
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit master = projectOperations.project(project).getHead("master");
     PushOneCommit stableTip =
         pushFactory.create(admin.newIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
     PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
@@ -615,7 +691,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchNonTip() throws Exception {
+  public void submitMergeOfNonChangeBranchNonTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -626,7 +702,7 @@
     // | /
     // I -- master
     //
-    RevCommit initial = getRemoteHead(project, "master");
+    RevCommit initial = projectOperations.project(project).getHead("master");
     // push directly to stable to S1
     PushOneCommit.Result s1 =
         pushFactory
@@ -659,11 +735,11 @@
   }
 
   @Test
-  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
+  public void submitChangeWithCommitThatWasAlreadyMerged() throws Throwable {
     // create and submit a change
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the change back to NEW to simulate a failed submit that
     // merged the commit but failed to update the change status
@@ -672,11 +748,11 @@
     // submitting the change again should detect that the commit was already
     // merged and just fix the change status to be MERGED
     submit(change.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Throwable {
     // create and submit 2 changes
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -686,7 +762,7 @@
     }
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the changes back to NEW to simulate a failed submit that
     // merged the commits but failed to update the change status
@@ -696,11 +772,11 @@
     // merged and just fix the change status to be MERGED
     submit(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     // create and submit 2 changes with the same topic
@@ -710,7 +786,7 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the second change back to NEW to simulate a failed
     // submit that merged the commits but failed to update the change status of
@@ -720,33 +796,38 @@
     // submitting the topic again should detect that the commits were already
     // merged and just fix the change status to be MERGED
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitWithValidation() throws Exception {
+  public void submitWithValidation() throws Throwable {
     AtomicBoolean called = new AtomicBoolean(false);
-    this.addOnSubmitValidationListener(
-        args -> {
-          called.set(true);
-          HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
-          assertThat(refs).contains("refs/heads/master");
-          refs.remove("refs/heads/master");
-          if (!refs.isEmpty()) {
-            // Some submit strategies need to insert new patchset.
-            assertThat(refs).hasSize(1);
-            assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
+    OnSubmitValidationListener listener =
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            called.set(true);
+            HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
+            assertThat(refs).contains("refs/heads/master");
+            refs.remove("refs/heads/master");
+            if (!refs.isEmpty()) {
+              // Some submit strategies need to insert new patchset.
+              assertThat(refs).hasSize(1);
+              assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
+            }
           }
-        });
+        };
 
-    PushOneCommit.Result change = createChange();
-    approve(change.getChangeId());
-    submit(change.getChangeId());
-    assertThat(called.get()).isTrue();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      PushOneCommit.Result change = createChange();
+      approve(change.getChangeId());
+      submit(change.getChangeId());
+      assertThat(called.get()).isTrue();
+    }
   }
 
   @Test
-  public void submitWithValidationMultiRepo() throws Exception {
+  public void submitWithValidationMultiRepo() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -777,42 +858,47 @@
     // Since there are 2 repos, first submit attempt will fail, the second will
     // succeed.
     List<String> projectsCalled = new ArrayList<>(4);
-    this.addOnSubmitValidationListener(
-        args -> {
-          String master = "refs/heads/master";
-          assertThat(args.getCommands()).containsKey(master);
-          ReceiveCommand cmd = args.getCommands().get(master);
-          ObjectId newMasterId = cmd.getNewId();
-          try (Repository repo = repoManager.openRepository(args.getProject())) {
-            assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
-            assertThat(args.getRef(master)).hasValue(newMasterId);
-            args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
-          } catch (IOException e) {
-            throw new AssertionError("failed checking new ref value", e);
+    OnSubmitValidationListener listener =
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            String master = "refs/heads/master";
+            assertThat(args.getCommands()).containsKey(master);
+            ReceiveCommand cmd = args.getCommands().get(master);
+            ObjectId newMasterId = cmd.getNewId();
+            try (Repository repo = repoManager.openRepository(args.getProject())) {
+              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+              assertThat(args.getRef(master)).hasValue(newMasterId);
+              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
+            } catch (IOException e) {
+              throw new AssertionError("failed checking new ref value", e);
+            }
+            projectsCalled.add(args.getProject().get());
+            if (projectsCalled.size() == 2) {
+              throw new ValidationException("time to fail");
+            }
           }
-          projectsCalled.add(args.getProject().get());
-          if (projectsCalled.size() == 2) {
-            throw new ValidationException("time to fail");
-          }
-        });
-    submitWithConflict(change4.getChangeId(), "time to fail");
-    assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get());
-    for (PushOneCommit.Result change : changes) {
-      change.assertChange(Change.Status.NEW, name(topic), admin);
-    }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      submitWithConflict(change4.getChangeId(), "time to fail");
+      assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get());
+      for (PushOneCommit.Result change : changes) {
+        change.assertChange(Change.Status.NEW, name(topic), admin);
+      }
 
-    submit(change4.getChangeId());
-    assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get(), keyA.get(), keyB.get());
-    for (PushOneCommit.Result change : changes) {
-      change.assertChange(Change.Status.MERGED, name(topic), admin);
+      submit(change4.getChangeId());
+      assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get(), keyA.get(), keyB.get());
+      for (PushOneCommit.Result change : changes) {
+        change.assertChange(Change.Status.MERGED, name(topic), admin);
+      }
     }
   }
 
   @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+  public void submitWithCommitAndItsMergeCommitTogether() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create a stable branch and bootstrap it.
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
@@ -820,8 +906,8 @@
         pushFactory.create(user.newIdent(), testRepo, "initial commit", "a.txt", "a");
     PushOneCommit.Result change = push.to("refs/heads/stable");
 
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
 
     assertThat(master).isEqualTo(initialHead);
     assertThat(stable).isEqualTo(change.getCommit());
@@ -874,13 +960,13 @@
     assertMerged(mergeId);
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
+    master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     assertThat(rw.isMergedInto(merge, master)).isTrue();
     assertThat(rw.isMergedInto(fix, master)).isTrue();
   }
 
   @Test
-  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
+  public void retrySubmitSingleChangeOnLockFailure() throws Throwable {
     PushOneCommit.Result change = createChange();
     String id = change.getChangeId();
     approve(id);
@@ -897,7 +983,7 @@
 
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
+    RevCommit master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     RevCommit patchSet = parseCurrentRevision(rw, change.getChangeId());
     assertThat(rw.isMergedInto(patchSet, master)).isTrue();
 
@@ -905,7 +991,7 @@
   }
 
   @Test
-  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
@@ -940,13 +1026,13 @@
 
     repoA.git().fetch().call();
     RevWalk rwA = repoA.getRevWalk();
-    RevCommit masterA = rwA.parseCommit(getRemoteHead(keyA, "master"));
+    RevCommit masterA = rwA.parseCommit(projectOperations.project(keyA).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rwA, change1.getChangeId());
     assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
 
     repoB.git().fetch().call();
     RevWalk rwB = repoB.getRevWalk();
-    RevCommit masterB = rwB.parseCommit(getRemoteHead(keyB, "master"));
+    RevCommit masterB = rwB.parseCommit(projectOperations.project(keyB).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rwB, change2.getChangeId());
     assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
 
@@ -954,14 +1040,14 @@
   }
 
   @Test
-  public void authorAndCommitDateAreEqual() throws Exception {
+  public void authorAndCommitDateAreEqual() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     ConfigInput ci = new ConfigInput();
     ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(ci);
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
@@ -975,12 +1061,12 @@
     }
 
     submit(change2.getChangeId());
-    assertAuthorAndCommitDateEquals(getRemoteHead());
+    assertAuthorAndCommitDateEquals(projectOperations.project(project).getHead("master"));
   }
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -997,7 +1083,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -1010,18 +1096,20 @@
     ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
     approve(revert2.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + revert2.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    revert2.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revert2.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + revert2.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1033,7 +1121,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1041,53 +1129,55 @@
     ChangeApi change = gApi.changes().create(ci);
     approve(change.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + change.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    change.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> change.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + change.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change =
         pushFactory
             .create(admin.newIdent(), testRepo, "Change 1", ImmutableMap.of())
             .to("refs/for/master");
     change.assertOkStatus();
-    // TODO(dborowitz): Use EMPTY_TREE_ID after upgrading to https://git.eclipse.org/r/127473
-    assertThat(change.getCommit().getTree())
-        .isEqualTo(ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904"));
+    assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
-  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
+  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
           batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
@@ -1106,7 +1196,61 @@
     }
   }
 
-  private void assertSubmitter(PushOneCommit.Result change) throws Exception {
+  @Test
+  @GerritConfig(name = "index.reindexAfterRefUpdate", value = "true")
+  public void submitSchedulesOpenChangesOfSameBranchForReindexing() throws Throwable {
+    // Create a merged change.
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Merged Change", "foo.txt", "foo");
+    PushOneCommit.Result mergedChange = push.to("refs/for/master");
+    mergedChange.assertOkStatus();
+    approve(mergedChange.getChangeId());
+    submit(mergedChange.getChangeId());
+
+    // Create some open changes.
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    PushOneCommit.Result change3 = createChange();
+
+    // Create a branch with one open change.
+    BranchInput in = new BranchInput();
+    in.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch("dev").create(in);
+    PushOneCommit.Result changeOtherBranch = createChange("refs/for/dev");
+
+    ChangeIndexedListener changeIndexedListener = mock(ChangeIndexedListener.class);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedListener)) {
+      // submit a change, this should trigger asynchronous reindexing of the open changes on the
+      // same branch
+      approve(change1.getChangeId());
+      submit(change1.getChangeId());
+      assertThat(gApi.changes().id(change1.getChangeId()).get().status)
+          .isEqualTo(ChangeStatus.MERGED);
+
+      // on submit the change that is submitted gets reindexed synchronously
+      verify(changeIndexedListener, atLeast(1))
+          .onChangeScheduledForIndexing(project.get(), change1.getChange().getId().get());
+      verify(changeIndexedListener, atLeast(1))
+          .onChangeIndexed(project.get(), change1.getChange().getId().get());
+
+      // the open changes on the same branch get reindexed asynchronously
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), change2.getChange().getId().get());
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), change3.getChange().getId().get());
+
+      // merged changes don't get reindexed
+      verify(changeIndexedListener, times(0))
+          .onChangeScheduledForIndexing(project.get(), mergedChange.getChange().getId().get());
+
+      // open changes on other branches don't get reindexed
+      verify(changeIndexedListener, times(0))
+          .onChangeScheduledForIndexing(project.get(), changeOtherBranch.getChange().getId().get());
+    }
+  }
+
+  private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
@@ -1129,102 +1273,86 @@
     }
   }
 
-  protected void submit(String changeId) throws Exception {
+  protected void submit(String changeId) throws Throwable {
     submit(changeId, new SubmitInput(), null, null);
   }
 
-  protected void submit(String changeId, SubmitInput input) throws Exception {
+  protected void submit(String changeId, SubmitInput input) throws Throwable {
     submit(changeId, input, null, null);
   }
 
-  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
+  protected void submitWithConflict(String changeId, String expectedError) throws Throwable {
     submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
   }
 
   protected void submit(
       String changeId,
       SubmitInput input,
-      Class<? extends RestApiException> expectedExceptionType,
+      @Nullable Class<? extends RestApiException> expectedExceptionType,
       String expectedExceptionMsg)
-      throws Exception {
+      throws Throwable {
     approve(changeId);
     if (expectedExceptionType == null) {
       assertSubmittable(changeId);
+    } else {
+      requireNonNull(expectedExceptionMsg);
     }
-    try {
-      gApi.changes().id(changeId).current().submit(input);
-      if (expectedExceptionType != null) {
-        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
-      }
-    } catch (RestApiException e) {
-      if (expectedExceptionType == null) {
-        throw e;
-      }
-      // More verbose than using assertThat and/or ExpectedException, but gives
-      // us the stack trace.
-      if (!expectedExceptionType.isAssignableFrom(e.getClass())
-          || !e.getMessage().equals(expectedExceptionMsg)) {
-        throw new AssertionError(
-            "Expected exception of type "
-                + expectedExceptionType.getSimpleName()
-                + " with message: \""
-                + expectedExceptionMsg
-                + "\" but got exception of type "
-                + e.getClass().getSimpleName()
-                + " with message \""
-                + e.getMessage()
-                + "\"",
-            e);
-      }
+    ThrowingRunnable submit = () -> gApi.changes().id(changeId).current().submit(input);
+    if (expectedExceptionType != null) {
+      RestApiException thrown = assertThrows(expectedExceptionType, submit);
+      assertThat(thrown).hasMessageThat().isEqualTo(expectedExceptionMsg);
       return;
     }
+    submit.run();
     ChangeInfo change = gApi.changes().id(changeId).info();
     assertMerged(change.changeId);
   }
 
-  protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(get(changeId, SUBMITTABLE).submittable).named("submit bit on ChangeInfo").isTrue();
+  protected void assertSubmittable(String changeId) throws Throwable {
+    assertWithMessage("submit bit on ChangeInfo")
+        .that(get(changeId, SUBMITTABLE).submittable)
+        .isTrue();
     RevisionResource rsrc = parseCurrentRevisionResource(changeId);
     UiAction.Description desc = submitHandler.getDescription(rsrc);
-    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
-    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
+    assertWithMessage("visible bit on submit action").that(desc.isVisible()).isTrue();
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
-  protected void assertChangeMergedEvents(String... expected) throws Exception {
+  protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
 
-  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
+  protected void assertRefUpdatedEvents(RevCommit... expected) throws Throwable {
     eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
   }
 
   protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
-      throws Exception {
+      throws Throwable {
     ChangeInfo c = get(changeId, CURRENT_REVISION);
     assertThat(c.currentRevision).isEqualTo(expectedId.name());
     assertThat(c.revisions.get(expectedId.name())._number).isEqualTo(expectedNum);
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(c.project))) {
-      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum).toRefName();
+    try (Repository repo = repoManager.openRepository(Project.nameKey(c.project))) {
+      String refName = PatchSet.id(Change.id(c._number), expectedNum).toRefName();
       Ref ref = repo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
+      assertWithMessage(refName).that(ref).isNotNull();
       assertThat(ref.getObjectId()).isEqualTo(expectedId);
     }
   }
 
-  protected void assertNew(String changeId) throws Exception {
+  protected void assertNew(String changeId) throws Throwable {
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
   }
 
-  protected void assertApproved(String changeId) throws Exception {
+  protected void assertApproved(String changeId) throws Throwable {
     assertApproved(changeId, admin);
   }
 
-  protected void assertApproved(String changeId, TestAccount user) throws Exception {
+  protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
     LabelInfo cr = c.labels.get("Code-Review");
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
-    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(user.id());
+    assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
   }
 
   protected void assertMerged(String changeId) throws RestApiException {
@@ -1244,40 +1372,40 @@
         .isEqualTo(commit.getCommitterIdent().getTimeZone());
   }
 
-  protected void assertSubmitter(String changeId, int psId) throws Exception {
+  protected void assertSubmitter(String changeId, int psId) throws Throwable {
     assertSubmitter(changeId, psId, admin);
   }
 
-  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
+  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNotNull();
     assertThat(submitter.isLegacySubmit()).isTrue();
-    assertThat(submitter.getAccountId()).isEqualTo(user.id());
+    assertThat(submitter.accountId()).isEqualTo(user.id());
   }
 
-  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
+  protected void assertNoSubmitter(String changeId, int psId) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
   }
 
   protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
-      throws Exception {
+      throws Throwable {
     assertRebase(testRepo, contentMerge);
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
     assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
   }
 
-  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Exception {
+  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Throwable {
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo, "HEAD");
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
     assertThat(remoteHead.getParentCount()).isEqualTo(1);
     if (!contentMerge) {
@@ -1286,7 +1414,7 @@
     assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
   }
 
-  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Exception {
+  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
@@ -1294,22 +1422,17 @@
     }
   }
 
-  protected List<RevCommit> getRemoteLog() throws Exception {
+  protected List<RevCommit> getRemoteLog() throws Throwable {
     return getRemoteLog(project, "master");
   }
 
-  protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
-    assertThat(onSubmitValidatorHandle).isNull();
-    onSubmitValidatorHandle = onSubmitValidationListeners.add("gerrit", listener);
-  }
-
-  private String getLatestDiff(Repository repo) throws Exception {
+  private String getLatestDiff(Repository repo) throws Throwable {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
     return getLatestDiff(repo, oldTreeId, newTreeId);
   }
 
-  private String getLatestRemoteDiff() throws Exception {
+  private String getLatestRemoteDiff() throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
@@ -1319,7 +1442,7 @@
   }
 
   private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
-      throws Exception {
+      throws Throwable {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     try (DiffFormatter fmt = new DiffFormatter(out)) {
       fmt.setRepository(repo);
@@ -1330,15 +1453,19 @@
   }
 
   // TODO(hanwen): the submodule tests have a similar method; maybe we could share code?
-  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Throwable {
     Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
     return project;
   }
 
   protected PushOneCommit.Result createChange(
-      String subject, String fileName, String content, String topic) throws Exception {
+      String subject, String fileName, String content, String topic) throws Throwable {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master/" + name(topic));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 36a09fd..a4fa84b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -19,23 +19,26 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
-  public void submitWithMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
@@ -43,17 +46,17 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
+  public void submitWithContentMerge() throws Throwable {
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
@@ -61,12 +64,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -78,22 +81,23 @@
             + "Change could not be merged due to a path conflict. "
             + "Please rebase the change locally "
             + "and upload the rebased commit for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(oldHead);
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
     approve(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change2.getCommit());
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result change1 =
         pushFactory
@@ -108,7 +112,7 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParents()).hasLength(2);
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index c12adfa..fff67f3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -17,21 +17,25 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+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.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -40,6 +44,7 @@
 import org.junit.Test;
 
 public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
@@ -47,42 +52,41 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
+  public void submitWithRebase() throws Throwable {
     submitWithRebase(admin);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          REGISTERED_USERS,
-          "refs/heads/*");
-      u.save();
-    }
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     submitWithRebase(user);
   }
 
   protected ImmutableList<PushOneCommit.Result> submitWithRebase(TestAccount submitter)
-      throws Exception {
+      throws Throwable {
     requestScopeOperations.setApiUser(submitter.id());
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change2.getChangeId(), submitter);
     assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
@@ -102,11 +106,11 @@
   }
 
   @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithRebaseMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
     } else {
@@ -127,7 +131,7 @@
     assertApproved(change3.getChangeId());
     assertApproved(change4.getChangeId());
 
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    RevCommit headAfterSecondSubmit = parse(projectOperations.project(project).getHead("master"));
     assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
     assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
     assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
@@ -163,7 +167,7 @@
   }
 
   @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
+  public void submitWithRebaseMergeCommit() throws Throwable {
     /*
        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
        |\
@@ -175,7 +179,7 @@
        |/
        * Initial empty repository
     */
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
 
     PushOneCommit change2Push =
@@ -193,7 +197,7 @@
     approve(change2.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(2);
 
     RevCommit headParent1 = parse(newHead.getParent(0).getId());
@@ -219,12 +223,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -232,7 +236,7 @@
         "Cannot rebase "
             + change2.getCommit().name()
             + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
@@ -241,7 +245,7 @@
     assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
   }
 
-  protected RevCommit parse(ObjectId id) throws Exception {
+  protected RevCommit parse(ObjectId id) throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit c = rw.parseCommit(id);
@@ -251,8 +255,8 @@
   }
 
   @Test
-  public void submitAfterReorderOfCommits() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitAfterReorderOfCommits() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create two commits and push.
     RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -271,15 +275,15 @@
     approve(id1);
     approve(id2);
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
   }
 
   @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitChangesAfterBranchOnSecond() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
@@ -287,13 +291,13 @@
     PushOneCommit.Result change2 = createChange();
     approve(change2.getChangeId());
     Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     createBranchWithRevision(branch, change2.getCommit().getName());
     gApi.changes().id(change2.getChangeId()).current().submit();
     assertMerged(change2.getChangeId());
     assertMerged(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(this.project).getHead("master");
     assertRefUpdatedEvents(initialHead, newHead);
     assertChangeMergedEvents(
         change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
@@ -301,8 +305,8 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitFastForwardIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitFastForwardIdenticalTree() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
@@ -313,18 +317,18 @@
     testRepo.reset(initialHead);
     PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
     submit(change0.getChangeId());
-    RevCommit headAfterChange0 = getRemoteHead();
+    RevCommit headAfterChange0 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
 
     submit(change1.getChangeId());
-    RevCommit headAfterChange1 = getRemoteHead();
+    RevCommit headAfterChange1 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
     assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
 
     // Do manual rebase first.
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
-    RevCommit headAfterChange2 = getRemoteHead();
+    RevCommit headAfterChange2 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
     assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
 
@@ -334,7 +338,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOne() throws Exception {
+  public void submitChainOneByOne() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
     submit(change1.getChangeId());
@@ -343,7 +347,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainFailsOnRework() throws Exception {
+  public void submitChainFailsOnRework() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     RevCommit headAfterChange1 = change1.getCommit();
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
@@ -351,7 +355,7 @@
     change1 =
         amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
     submit(change1.getChangeId());
-    headAfterChange1 = getRemoteHead();
+    headAfterChange1 = projectOperations.project(project).getHead("master");
 
     submitWithConflict(
         change2.getChangeId(),
@@ -359,13 +363,13 @@
             + change2.getCommit().getName()
             + ": "
             + "The change could not be rebased due to a conflict during merge.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOneManualRebase() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitChainOneByOneManualRebase() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e7f3d54..dda7bbd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -22,9 +22,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -32,11 +37,8 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -45,8 +47,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 public class ActionsIT extends AbstractDaemonTest {
@@ -55,23 +55,9 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private DynamicSet<ActionVisitor> actionVisitors;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RevisionJson.Factory revisionJsonFactory;
-
-  private RegistrationHandle visitorHandle;
-
-  @Before
-  public void setUp() {
-    visitorHandle = null;
-  }
-
-  @After
-  public void tearDown() {
-    if (visitorHandle != null) {
-      visitorHandle.remove();
-    }
-  }
+  @Inject private ExtensionRegistry extensionRegistry;
 
   protected Map<String, ActionInfo> getActions(String id) throws Exception {
     return gApi.changes().id(id).revision(1).actions();
@@ -206,6 +192,45 @@
   }
 
   @Test
+  public void pluginCanContributeToETagComputation() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
+      assertThat(getETag(change)).isNotEqualTo(oldETag);
+    }
+
+    assertThat(getETag(change)).isEqualTo(oldETag);
+  }
+
+  @Test
+  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
+      assertThat(getETag(change)).isEqualTo(oldETag);
+    }
+  }
+
+  @Test
+  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                TestChangeETagComputation.withException(
+                    new StorageException("exception during test")))) {
+      assertThat(getETag(change)).isEqualTo(oldETag);
+    }
+  }
+
+  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
@@ -331,19 +356,18 @@
     assertThat(origActions.keySet()).containsAtLeast("followup", "abandon");
     assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
 
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add("gerrit", v);
+    try (Registration registration = extensionRegistry.newRegistration().add(new Visitor())) {
+      Map<String, ActionInfo> newActions =
+          gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
 
-    Map<String, ActionInfo> newActions =
-        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
+      Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+      expectedNames.remove("followup");
+      assertThat(newActions.keySet()).isEqualTo(expectedNames);
 
-    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
-    expectedNames.remove("followup");
-    assertThat(newActions.keySet()).isEqualTo(expectedNames);
-
-    ActionInfo abandon = newActions.get("abandon");
-    assertThat(abandon).isNotNull();
-    assertThat(abandon.label).isEqualTo("Abandon All Hope");
+      ActionInfo abandon = newActions.get("abandon");
+      assertThat(abandon).isNotNull();
+      assertThat(abandon.label).isEqualTo("Abandon All Hope");
+    }
   }
 
   @Test
@@ -351,7 +375,7 @@
     String id = createChange().getChangeId();
     amendChange(id);
     ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-    Change.Id changeId = new Change.Id(origChange._number);
+    Change.Id changeId = Change.id(origChange._number);
 
     class Visitor implements ActionVisitor {
       @Override
@@ -380,22 +404,22 @@
     assertThat(origActions.keySet()).containsAtLeast("cherrypick", "rebase");
     assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
 
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add("gerrit", v);
+    try (Registration registration = extensionRegistry.newRegistration().add(new Visitor())) {
+      // Test different codepaths within ActionJson...
+      // ...via revision API.
+      visitedCurrentRevisionActionsAssertions(
+          origActions, gApi.changes().id(id).current().actions());
 
-    // Test different codepaths within ActionJson...
-    // ...via revision API.
-    visitedCurrentRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
+      // ...via change API with option.
+      EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
+      ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
+      RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
+      visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
 
-    // ...via change API with option.
-    EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
-    ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
-    RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
-    visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
-
-    // ...via ChangeJson directly.
-    ChangeData cd = changeDataFactory.create(project, changeId);
-    revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
+      // ...via ChangeJson directly.
+      ChangeData cd = changeDataFactory.create(project, changeId);
+      revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(PatchSet.id(changeId, 1)));
+    }
   }
 
   private void visitedCurrentRevisionActionsAssertions(
@@ -440,18 +464,17 @@
     assertThat(origActions.keySet()).containsExactly("description");
     assertThat(origActions.get("description").label).isEqualTo("Edit Description");
 
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add("gerrit", v);
+    try (Registration registration = extensionRegistry.newRegistration().add(new Visitor())) {
+      // Unlike for the current revision, actions for old revisions are only available via the
+      // revision API.
+      Map<String, ActionInfo> newActions = gApi.changes().id(id).revision(1).actions();
+      assertThat(newActions).isNotNull();
+      assertThat(newActions.keySet()).isEqualTo(origActions.keySet());
 
-    // Unlike for the current revision, actions for old revisions are only available via the
-    // revision API.
-    Map<String, ActionInfo> newActions = gApi.changes().id(id).revision(1).actions();
-    assertThat(newActions).isNotNull();
-    assertThat(newActions.keySet()).isEqualTo(origActions.keySet());
-
-    ActionInfo description = newActions.get("description");
-    assertThat(description).isNotNull();
-    assertThat(description.label).isEqualTo("Describify");
+      ActionInfo description = newActions.get("description");
+      assertThat(description).isNotNull();
+      assertThat(description.label).isEqualTo("Describify");
+    }
   }
 
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 2d6227b..0f5def6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -15,47 +15,38 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
 import org.eclipse.jgit.transport.RefSpec;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
 @NoHttpd
+@UseClockStep
 public class AssigneeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
   @Test
   public void getNoAssignee() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -93,16 +84,32 @@
   }
 
   @Test
-  public void assigneeAddedAsReviewer() throws Exception {
-    ReviewerState state = ReviewerState.CC;
+  public void assigneeAddedAsCc() throws Exception {
     PushOneCommit.Result r = createChange();
-    Iterable<AccountInfo> reviewers = getReviewers(r, state);
+    Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.CC);
     assertThat(reviewers).isNull();
+
     assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-    reviewers = getReviewers(r, state);
+    reviewers = getReviewers(r, ReviewerState.CC);
     assertThat(reviewers).hasSize(1);
-    AccountInfo reviewer = Iterables.getFirst(reviewers, null);
-    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
+    assertThat(getReviewers(r, ReviewerState.REVIEWER)).isNull();
+  }
+
+  @Test
+  public void assigneeStaysReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
+    assertThat(getReviewers(r, ReviewerState.CC)).isNull();
+
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
+    reviewers = getReviewers(r, ReviewerState.REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
+    assertThat(getReviewers(r, ReviewerState.CC)).isNull();
   }
 
   @Test
@@ -130,20 +137,17 @@
   public void setAssigneeToInactiveUser() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.accounts().id(user.id().get()).setActive(false);
-    try {
-      setAssignee(r, user.email());
-      assert_().fail("expected UnresolvableAccountException");
-    } catch (UnresolvableAccountException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Account '"
-                  + user.email()
-                  + "' only matches inactive accounts. To use an inactive account, retry with one"
-                  + " of the following exact account IDs:\n"
-                  + user.id()
-                  + ": User <user@example.com>");
-    }
+    UnresolvableAccountException thrown =
+        assertThrows(UnresolvableAccountException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Account '"
+                + user.email()
+                + "' only matches inactive accounts. To use an inactive account, retry with one"
+                + " of the following exact account IDs:\n"
+                + user.id()
+                + ": User <user@example.com>");
   }
 
   @Test
@@ -159,24 +163,26 @@
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
     PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
-    exception.expect(AuthException.class);
-    exception.expectMessage("read not permitted");
-    setAssignee(r, user.email());
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("read not permitted");
   }
 
   @Test
   public void setAssigneeNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted");
-    setAssignee(r, user.email());
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("not permitted");
   }
 
   @Test
   public void setAssigneeAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_ASSIGNEE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/BUILD b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
index 7ccf10f..1eddcce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
@@ -20,14 +20,14 @@
     ],
 )
 
-acceptance_tests(
-    srcs = SUBMIT_TESTS,
-    group = "rest_change_submit",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["rest"],
     deps = [
         ":submit_util",
     ],
-)
+) for f in SUBMIT_TESTS]
 
 java_library(
     name = "submit_util",
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index 3f1608c..bc52681 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.reviewdb.client.Change;
 import org.junit.Test;
 
 public class ChangeIdIT extends AbstractDaemonTest {
@@ -98,14 +98,14 @@
     // This test tests a redirect that is primarily intended for the UI (though the backend doesn't
     // really care who the caller is). The redirect rewrites a shorthand change number URL (/123) to
     // it's canonical long form (/c/project/+/123).
-    int changeId = createChange().getChange().getId().id;
+    int changeId = createChange().getChange().getId().get();
     RestResponse res = anonymousRestSession.get("/" + changeId);
     res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
   }
 
   @Test
   public void changeNumberRedirectsWithTrailingSlash() throws Exception {
-    int changeId = createChange().getChange().getId().id;
+    int changeId = createChange().getChange().getId().get();
     RestResponse res = anonymousRestSession.get("/" + changeId + "/");
     res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
   }
@@ -125,8 +125,8 @@
   @Test
   public void hiddenChangeNotFound() throws Exception {
     Change.Id changeId = createChange().getChange().getId();
-    gApi.changes().id(changeId.id).setPrivate(true, null);
-    RestResponse res = anonymousRestSession.get("/" + changeId.id);
+    gApi.changes().id(changeId.get()).setPrivate(true, null);
+    RestResponse res = anonymousRestSession.get("/" + changeId.get());
     res.assertNotFound();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 59b6e29..47fb20a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -15,20 +15,25 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class ChangeIncludedInIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void includedInOpenChange() throws Exception {
     Result result = createChange();
@@ -49,13 +54,17 @@
         .containsExactly("master");
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
 
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
         .containsExactly("test-tag");
 
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+    createBranch(BranchNameKey.create(project, "test-branch"));
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
         .containsExactly("master", "test-branch");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 51c5fc8..8cfcbab 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -14,12 +14,15 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
-import static java.util.concurrent.TimeUnit.SECONDS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
@@ -28,8 +31,12 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -37,10 +44,8 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
@@ -49,29 +54,16 @@
 import java.util.Optional;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.RawParseUtils;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+@UseClockStep
+@UseTimezone(timezone = "US/Eastern")
 @RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
   @Test
   public void messagesNotReturnedByDefault() throws Exception {
     String changeId = createChange().getChangeId();
@@ -159,17 +151,17 @@
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
 
-    try {
-      deleteOneChangeMessage(changeNum, 0, user, "spam");
-      fail("expected AuthException");
-    } catch (AuthException e) {
-      assertThat(e.getMessage()).isEqualTo("administrate server not permitted");
-    }
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> deleteOneChangeMessage(changeNum, 0, user, "spam"));
+    assertThat(thrown).hasMessageThat().isEqualTo("administrate server not permitted");
   }
 
   @Test
   public void deleteCanBeAppliedWithAdministrateServerCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
     deleteOneChangeMessage(changeNum, 0, user, "spam");
@@ -179,12 +171,15 @@
   public void deleteCannotBeAppliedWithEmptyChangeMessageUuid() throws Exception {
     String changeId = createChange().getChangeId();
 
-    try {
-      gApi.changes().id(changeId).message("").delete(new DeleteChangeMessageInput("spam"));
-      fail("expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).isEqualTo("change message  not found");
-    }
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .message("")
+                    .delete(new DeleteChangeMessageInput("spam")));
+    assertThat(thrown).hasMessageThat().isEqualTo("change message  not found");
   }
 
   @Test
@@ -194,12 +189,11 @@
     String id = "8473b95934b5732ac55d26311a706c9c2bde9941";
     input.reason = "spam";
 
-    try {
-      gApi.changes().id(changeId).message(id).delete(input);
-      fail("expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).isEqualTo(String.format("change message %s not found", id));
-    }
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeId).message(id).delete(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(String.format("change message %s not found", id));
   }
 
   @Test
@@ -279,7 +273,7 @@
     List<ChangeMessageInfo> messagesBeforeDeletion = gApi.changes().id(changeNum).messages();
 
     List<CommentInfo> commentsBefore = getChangeSortedComments(changeNum);
-    List<RevCommit> commitsBefore = getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    List<RevCommit> commitsBefore = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
 
     String id = messagesBeforeDeletion.get(deletedMessageIndex).id;
     DeleteChangeMessageInput input = new DeleteChangeMessageInput(reason);
@@ -306,8 +300,8 @@
       int deletedMessageIndex,
       TestAccount deletedBy,
       String deleteReason) {
-    assertThat(messagesAfterDeletion)
-        .named("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+    assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+        .that(messagesAfterDeletion)
         .hasSize(messagesBeforeDeletion.size());
 
     for (int i = 0; i < messagesAfterDeletion.size(); ++i) {
@@ -340,8 +334,7 @@
       TestAccount deletedBy,
       String deleteReason)
       throws Exception {
-    List<RevCommit> commitsAfterDeletion =
-        getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    List<RevCommit> commitsAfterDeletion = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
     assertThat(commitsAfterDeletion).hasSize(commitsBeforeDeletion.size());
 
     for (int i = 0; i < commitsBeforeDeletion.size(); i++) {
@@ -356,8 +349,8 @@
             parseCommitMessageRange(commitBefore);
         Optional<ChangeNoteUtil.CommitMessageRange> rangeAfter =
             parseCommitMessageRange(commitAfter);
-        assertThat(rangeBefore.isPresent()).isTrue();
-        assertThat(rangeAfter.isPresent()).isTrue();
+        assertThat(rangeBefore).isPresent();
+        assertThat(rangeAfter).isPresent();
 
         String subjectBefore =
             decode(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index d51221e..10194eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -14,6 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -21,10 +26,10 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -121,8 +126,7 @@
   }
 
   private void assertApproveFails(TestAccount a, String changeId) throws Exception {
-    exception.expect(AuthException.class);
-    approve(a, changeId);
+    assertThrows(AuthException.class, () -> approve(a, changeId));
   }
 
   private void grantApproveToChangeOwner(Project.NameKey project) throws Exception {
@@ -139,11 +143,24 @@
 
   private void grantApprove(Project.NameKey project, AccountGroup.UUID groupUUID, boolean exclusive)
       throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, groupUUID, exclusive);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .update();
   }
 
   private void blockApproveForChangeOwner(Project.NameKey project) throws Exception {
-    blockLabel("Code-Review", -2, 2, SystemGroupBackend.CHANGE_OWNER, "refs/heads/*", project);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review")
+                .ref("refs/heads/*")
+                .group(SystemGroupBackend.CHANGE_OWNER)
+                .range(-2, 2))
+        .update();
   }
 
   private String createMyChange(TestRepository<InMemoryRepository> testRepo) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 173b78d..fbe6533 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -15,11 +15,14 @@
 package com.google.gerrit.acceptance.rest.change;
 
 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;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
@@ -31,8 +34,10 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -49,7 +54,6 @@
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
@@ -66,6 +70,7 @@
 public class ChangeReviewersIT extends AbstractDaemonTest {
 
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -332,20 +337,18 @@
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(1);
 
-    // Verify reviewer state. Both admin and user should be REVIEWERs now,
-    // because admin gets forced into REVIEWER state by virtue of being owner.
+    // Verify reviewer state.
     c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
     label = c.labels.get("Code-Review");
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
-    assertThat(label.all).hasSize(2);
+    assertThat(label.all).hasSize(1);
     Map<Integer, Integer> approvals = new HashMap<>();
     for (ApprovalInfo approval : label.all) {
       approvals.put(approval._accountId, approval.value);
     }
-    assertThat(approvals).containsEntry(admin.id().get(), 0);
     assertThat(approvals).containsEntry(user.id().get(), 0);
 
     // Comment as user without voting. This should delete the approval and
@@ -360,17 +363,16 @@
 
     // Verify reviewer state.
     c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
     label = c.labels.get("Code-Review");
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
-    assertThat(label.all).hasSize(2);
+    assertThat(label.all).hasSize(1);
     approvals.clear();
     for (ApprovalInfo approval : label.all) {
       approvals.put(approval._accountId, approval.value);
     }
-    assertThat(approvals).containsEntry(admin.id().get(), 0);
     assertThat(approvals).containsEntry(user.id().get(), 0);
   }
 
@@ -386,8 +388,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);
@@ -488,7 +489,7 @@
   }
 
   @Test
-  public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
+  public void addReviewerToReviewerChangeInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
@@ -502,7 +503,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();
@@ -662,9 +663,11 @@
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -674,7 +677,11 @@
     // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
     // rather than bypassing the check because of project or ref ownership.
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
-    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
@@ -690,9 +697,11 @@
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -705,9 +714,11 @@
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -742,6 +753,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/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 9a907aa..243991b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -15,47 +15,46 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ConfigChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.OWNER, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
-      u.save();
-    }
-
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     fetchRefsMetaConfig();
   }
@@ -75,8 +74,8 @@
   }
 
   private String testUpdateProjectConfig() throws Exception {
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("project", null, "description")).isNull();
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("project", null, "description").isNull();
     String desc = "new project description";
     cfg.setString("project", null, "description", desc);
 
@@ -89,7 +88,12 @@
     assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.projects().name(project.get()).get().description).isEqualTo(desc);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("project", null, "description")).isEqualTo(desc);
+    assertThat(
+            projectOperations
+                .project(project)
+                .getConfig()
+                .getString("project", null, "description"))
+        .isEqualTo(desc);
     String changeRev = gApi.changes().id(id).get().currentRevision;
     String branchRev =
         gApi.projects().name(project.get()).branch(RefNames.REFS_CONFIG).get().revision;
@@ -107,33 +111,31 @@
     gApi.projects().create(parent);
 
     requestScopeOperations.setApiUser(user.id());
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("access", null, "inheritFrom").isAnyOf(null, allProjects.get());
     cfg.setString("access", null, "inheritFrom", parent.name);
 
     PushOneCommit.Result r = createConfigChange(cfg);
     String id = r.getChangeId();
 
     gApi.changes().id(id).current().review(ReviewInput.approve());
-    try {
-      gApi.changes().id(id).current().submit();
-      fail("expected submit to fail");
-    } catch (ResourceConflictException e) {
-      int n = gApi.changes().id(id).info()._number;
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 1 change due to the following problems:\n"
-                  + "Change "
-                  + n
-                  + ": Change contains a project configuration that"
-                  + " changes the parent project.\n"
-                  + "The change must be submitted by a Gerrit administrator.");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(id).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Failed to submit 1 change due to the following problems:\n"
+                + "Change "
+                + gApi.changes().id(id).info()._number
+                + ": Change contains a project configuration that"
+                + " changes the parent project.\n"
+                + "The change must be submitted by a Gerrit administrator.");
 
     assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
+    assertThat(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
         .isAnyOf(null, allProjects.get());
 
     requestScopeOperations.setApiUser(admin.id());
@@ -141,7 +143,9 @@
     assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(parent.name);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom")).isEqualTo(parent.name);
+    assertThat(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
+        .isEqualTo(parent.name);
   }
 
   @Test
@@ -179,17 +183,6 @@
     testRepo.reset(RefNames.REFS_CONFIG);
   }
 
-  private Config readProjectConfig() throws Exception {
-    RevWalk rw = testRepo.getRevWalk();
-    RevTree tree = rw.parseTree(testRepo.getRepository().resolve("HEAD"));
-    RevObject obj = rw.parseAny(testRepo.get(tree, "project.config"));
-    ObjectLoader loader = rw.getObjectReader().open(obj);
-    String text = new String(loader.getCachedBytes(), UTF_8);
-    Config cfg = new Config();
-    cfg.fromText(text);
-    return cfg;
-  }
-
   private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
     PushOneCommit.Result r =
         pushFactory
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 9c42542..3b26459 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -26,6 +26,7 @@
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -80,11 +81,11 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
 
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+    assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
   }
 
   @Test
@@ -162,7 +163,7 @@
     res.assertOK();
 
     String vary = res.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
+    assertWithMessage(VARY).that(vary).isNotNull();
     assertThat(Splitter.on(", ").splitToList(vary))
         .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
     checkCors(res, true, origin);
@@ -213,7 +214,7 @@
         auth = c.getValue();
       }
     }
-    assertThat(auth).named("GerritAccount cookie").isNotNull();
+    assertWithMessage("GerritAccount cookie").that(auth).isNotNull();
     cookies.clear();
 
     UrlEncoded url =
@@ -232,16 +233,18 @@
     assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
 
     Header vary = r.getFirstHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary.getValue())).contains(ORIGIN);
 
     Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
-    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin.getValue()).isEqualTo(origin);
 
     Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
-    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowAuth).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS)
+        .that(allowAuth.getValue())
+        .isEqualTo("true");
 
     checkTopic(change, "test-xd");
   }
@@ -264,7 +267,7 @@
 
   private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
     ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
-    StringSubject t = assertThat(info.topic).named("topic");
+    StringSubject t = assertWithMessage("topic").that(info.topic);
     if (topic != null) {
       t.isEqualTo(topic);
     } else {
@@ -287,8 +290,8 @@
 
   private void checkCors(RestResponse r, boolean accept, String origin) {
     String vary = r.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary)).contains(ORIGIN);
 
     String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
     String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
@@ -296,28 +299,28 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
     if (accept) {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isEqualTo(origin);
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isEqualTo("true");
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isEqualTo("600");
 
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowMethods))
-          .named(ACCESS_CONTROL_ALLOW_METHODS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS)
+          .that(Splitter.on(", ").splitToList(allowMethods))
           .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
 
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowHeaders))
-          .named(ACCESS_CONTROL_ALLOW_HEADERS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS)
+          .that(Splitter.on(", ").splitToList(allowHeaders))
           .containsExactlyElementsIn(
               Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
                   .map(s -> s.toLowerCase(Locale.US))
                   .collect(ImmutableSet.toImmutableSet()));
     } else {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index bc675e5..a1167ed 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
@@ -27,7 +28,13 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseSystemTime;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -37,17 +44,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
@@ -64,23 +68,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
+@UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
     ChangeInput ci = new ChangeInput();
@@ -135,6 +129,13 @@
   }
 
   @Test
+  public void createNewChange_RequiresAuthentication() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    assertCreateFails(
+        newChangeInput(ChangeStatus.NEW), AuthException.class, "Authentication required");
+  }
+
+  @Test
   public void createNewChange() throws Exception {
     ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     assertThat(info.revisions.get(info.currentRevision).commit.message)
@@ -162,6 +163,31 @@
   }
 
   @Test
+  public void cannotCreateChangeWithChangeIfOfExistingChangeOnSameBranch() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject\n\nChange-Id: " + changeId;
+    assertCreateFails(
+        ci,
+        ResourceConflictException.class,
+        "A change with Change-Id " + changeId + " already exists for this branch.");
+  }
+
+  @Test
+  public void canCreateChangeWithChangeIfOfExistingChangeOnOtherBranch() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    createBranch(BranchNameKey.create(project, "other"));
+
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject\n\nChange-Id: " + changeId;
+    ci.branch = "other";
+    ChangeInfo info = assertCreateSucceeds(ci);
+    assertThat(info.changeId).isEqualTo(changeId);
+  }
+
+  @Test
   public void notificationsOnChangeCreation() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     watch(project.get());
@@ -300,7 +326,11 @@
   public void createChangeWithoutAccessToParentCommitFails() throws Exception {
     Map<String, PushOneCommit.Result> results =
         changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/invisible-branch").group(REGISTERED_USERS))
+        .update();
 
     ChangeInput in = newChangeInput(ChangeStatus.NEW);
     in.branch = "visible-branch";
@@ -315,7 +345,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
@@ -379,8 +409,8 @@
     // Create 2 branches.
     String branchA = "branchA";
     String branchB = "branchB";
-    createBranch(new Branch.NameKey(project, branchA));
-    createBranch(new Branch.NameKey(project, branchB));
+    createBranch(BranchNameKey.create(project, branchA));
+    createBranch(BranchNameKey.create(project, branchB));
 
     // Push an octopus merge to both of the branches.
     Result octopusA =
@@ -483,7 +513,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
 
     ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
@@ -491,45 +521,13 @@
   }
 
   @Test
-  public void sha1sOfTwoNewChangesDiffer() throws Exception {
-    ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
-    ChangeInfo info1 = assertCreateSucceeds(changeInput);
-    ChangeInfo info2 = assertCreateSucceeds(changeInput);
-    assertThat(info1.currentRevision).isNotEqualTo(info2.currentRevision);
-    assertThat(info1.changeId).isNotEqualTo(info2.changeId);
-  }
-
-  @Test
-  public void sha1sOfTwoNewChangesDifferIfCreatedConcurrently() throws Exception {
-    ExecutorService executor = Executors.newFixedThreadPool(2);
-    try {
-      for (int i = 0; i < 10; i++) {
-        ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
-
-        CyclicBarrier sync = new CyclicBarrier(2);
-        Callable<ChangeInfo> createChange =
-            () -> {
-              requestScopeOperations.setApiUser(admin.id());
-              sync.await();
-              return assertCreateSucceeds(changeInput);
-            };
-
-        Future<ChangeInfo> changeInfo1 = executor.submit(createChange);
-        Future<ChangeInfo> changeInfo2 = executor.submit(createChange);
-        assertThat(changeInfo1.get().currentRevision)
-            .isNotEqualTo(changeInfo2.get().currentRevision);
-        assertThat(changeInfo1.get().changeId).isNotEqualTo(changeInfo2.get().changeId);
-      }
-    } finally {
-      executor.shutdown();
-      executor.awaitTermination(5, TimeUnit.SECONDS);
-    }
-  }
-
-  @Test
   public void createChangeOnExistingBranchNotPermitted() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
-    blockRead("refs/heads/*");
+    createBranch(BranchNameKey.create(project, "foo"));
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.branch = "foo";
@@ -548,7 +546,11 @@
 
   @Test
   public void createChangeOnNonExistingBranchNotPermitted() throws Exception {
-    blockRead("refs/heads/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.branch = "foo";
@@ -568,6 +570,42 @@
         input, BadRequestException.class, "Cannot create merge: destination branch does not exist");
   }
 
+  @Test
+  @UseSystemTime
+  public void sha1sOfTwoNewChangesDiffer() throws Exception {
+    ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
+    ChangeInfo info1 = assertCreateSucceeds(changeInput);
+    ChangeInfo info2 = assertCreateSucceeds(changeInput);
+    assertThat(info1.currentRevision).isNotEqualTo(info2.currentRevision);
+  }
+
+  @Test
+  @UseSystemTime
+  public void sha1sOfTwoNewChangesDifferIfCreatedConcurrently() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      for (int i = 0; i < 10; i++) {
+        ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
+
+        CyclicBarrier sync = new CyclicBarrier(2);
+        Callable<ChangeInfo> createChange =
+            () -> {
+              requestScopeOperations.setApiUser(admin.id());
+              sync.await();
+              return assertCreateSucceeds(changeInput);
+            };
+
+        Future<ChangeInfo> changeInfo1 = executor.submit(createChange);
+        Future<ChangeInfo> changeInfo2 = executor.submit(createChange);
+        assertThat(changeInfo1.get().currentRevision)
+            .isNotEqualTo(changeInfo2.get().currentRevision);
+      }
+    } finally {
+      executor.shutdown();
+      executor.awaitTermination(5, TimeUnit.SECONDS);
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -604,9 +642,8 @@
   private void assertCreateFails(
       ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
       throws Exception {
-    exception.expect(errType);
-    exception.expectMessage(errSubstring);
-    gApi.changes().create(in);
+    Throwable thrown = assertThrows(errType, () -> gApi.changes().create(in));
+    assertThat(thrown).hasMessageThat().contains(errSubstring);
   }
 
   // TODO(davido): Expose setting of account preferences in the API
@@ -665,8 +702,8 @@
     initialCommit.assertOkStatus();
 
     // create two new branches
-    createBranch(new Branch.NameKey(project, branchA));
-    createBranch(new Branch.NameKey(project, branchB));
+    createBranch(BranchNameKey.create(project, branchA));
+    createBranch(BranchNameKey.create(project, branchB));
 
     // create a commit in branchA
     Result changeA =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 286980f..37fa2ce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -23,11 +23,11 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
@@ -104,6 +104,6 @@
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index c13df49..c57a035 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Objects.requireNonNull;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
@@ -25,29 +27,21 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
 @NoHttpd
+@UseClockStep
 public class HashtagsIT extends AbstractDaemonTest {
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
+  @Inject private ProjectOperations projectOperations;
 
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -78,9 +72,9 @@
   public void addInvalidHashtag() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("hashtags may not contain commas");
-    addHashtags(r, "invalid,hashtag");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addHashtags(r, "invalid,hashtag"));
+    assertThat(thrown).hasMessageThat().contains("hashtags may not contain commas");
   }
 
   @Test
@@ -258,15 +252,18 @@
   public void addHashtagWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit hashtags not permitted");
-    addHashtags(r, "MyHashtag");
+    AuthException thrown = assertThrows(AuthException.class, () -> addHashtags(r, "MyHashtag"));
+    assertThat(thrown).hasMessageThat().contains("edit hashtags not permitted");
   }
 
   @Test
   public void addHashtagWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_HASHTAGS).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
@@ -304,7 +301,7 @@
   private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
     ChangeMessageInfo lastMessage =
         Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
-    assertThat(lastMessage).named(lastMessage.message).isNotNull();
+    assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
     return lastMessage;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index f49d1fb..3030b02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.change;
 
 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.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -24,10 +26,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -48,7 +49,11 @@
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     userRestSession.post("/changes/" + changeId + "/index/").assertNotFound();
   }
 
@@ -62,15 +67,15 @@
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.READ,
-          groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
-          "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(
+            allow(Permission.READ)
+                .ref("refs/*")
+                .group(groupCache.get(AccountGroup.nameKey(group)).get().getGroupUUID()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Clone it and push a change as a regular user
     TestRepository<InMemoryRepository> repo = cloneProject(p, user);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 2448ff881c..063f1a0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,17 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -35,10 +40,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
@@ -48,15 +50,16 @@
 
 @NoHttpd
 public class MoveChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void moveChangeWithShortRef() throws Exception {
     // Move change to a different branch using short ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.getShortName());
+    move(r.getChangeId(), newBranch.shortName());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -64,9 +67,9 @@
   public void moveChangeWithFullRef() throws Exception {
     // Move change to a different branch using full ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.get());
+    move(r.getChangeId(), newBranch.branch());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -74,10 +77,10 @@
   public void moveChangeWithMessage() throws Exception {
     // Provide a message using --message flag
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     String moveMessage = "Moving for the move test";
-    move(r.getChangeId(), newBranch.get(), moveMessage);
+    move(r.getChangeId(), newBranch.branch(), moveMessage);
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
     StringBuilder expectedMessage = new StringBuilder();
     expectedMessage.append("Change destination moved from master to moveTest");
@@ -90,49 +93,59 @@
   public void moveChangeToSameRefAsCurrent() throws Exception {
     // Move change to the branch same as change's destination
     PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already destined for the specified branch");
-    move(r.getChangeId(), r.getChange().change().getDest().get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(r.getChangeId(), r.getChange().change().getDest().branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Change is already destined for the specified branch");
   }
 
   @Test
   public void moveChangeToSameChangeId() throws Exception {
     // Move change to a branch with existing change with same change ID
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     int changeNum = r.getChange().change().getChangeId();
-    createChange(newBranch.get(), r.getChangeId());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Destination "
-            + newBranch.getShortName()
-            + " has a different change with same change key "
-            + r.getChangeId());
-    move(changeNum, newBranch.get());
+    createChange(newBranch.branch(), r.getChangeId());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Destination "
+                + newBranch.shortName()
+                + " has a different change with same change key "
+                + r.getChangeId());
   }
 
   @Test
   public void moveChangeToNonExistentRef() throws Exception {
     // Move change to a non-existing branch
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "does_not_exist");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
-    move(r.getChangeId(), newBranch.get());
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "does_not_exist");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Destination " + newBranch.branch() + " not found in the project");
   }
 
   @Test
   public void moveClosedChange() throws Exception {
     // Move a change which is not open
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     merge(r);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is merged");
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Change is merged");
   }
 
   @Test
@@ -153,43 +166,51 @@
     pushHead(testRepo, "refs/for/master", false, false);
 
     // Try to move the merge commit to another branch
-    Branch.NameKey newBranch = new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r1.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Merge commit cannot be moved");
-    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(GitUtil.getChangeId(testRepo, c).get(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Merge commit cannot be moved");
   }
 
   @Test
   public void moveChangeToBranchWithoutUploadPerms() throws Exception {
     // Move change to a destination where user doesn't have upload permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
-    block(
-        "refs/for/" + newBranch.get(),
-        Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/" + newBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
   public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
     // Move change for which user does not have abandon permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    block(
-        r.getChange().change().getDest().get(),
-        Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.ABANDON)
+                .ref(r.getChange().change().getDest().branch())
+                .group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
@@ -202,52 +223,56 @@
     int changeNum = r.getChange().change().getChangeId();
 
     // Create a branch with that same commit
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     BranchInput bi = new BranchInput();
     bi.revision = r.getCommit().name();
-    gApi.projects().name(newBranch.getParentKey().get()).branch(newBranch.get()).create(bi);
+    gApi.projects().name(newBranch.project().get()).branch(newBranch.branch()).create(bi);
 
     // Try to move the change to the branch with the same commit
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Current patchset revision is reachable from tip of " + newBranch.get());
-    move(changeNum, newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Current patchset revision is reachable from tip of " + newBranch.branch());
   }
 
   @Test
   public void moveChangeWithCurrentPatchSetLocked() throws Exception {
     // Move change that is locked
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
+    LabelType patchSetLock = TestLabels.patchSetLock();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType patchSetLock = Util.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          registeredUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(0, 1))
+        .update();
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("The current patch set of change %s is locked", r.getChange().getId()));
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("The current patch set of change %s is locked", r.getChange().getId()));
   }
 
   @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
@@ -257,16 +282,13 @@
     configLabel(testLabelB, LabelFunction.MAX_NO_BLOCK);
     configLabel(testLabelC, LabelFunction.NO_BLOCK);
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelB).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelC).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     String changeId = createChange().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.reject());
@@ -301,17 +323,16 @@
   }
 
   @Test
-  public void moveToBranchWithoutLabel() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+  public void moveToBranchThatDoesNotHaveCustomLabel() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
     String testLabelA = "Label-A";
     configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/master");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     String changeId = createChange().getChangeId();
 
@@ -326,16 +347,26 @@
 
     move(changeId, "foo");
 
-    // TODO(dpursehouse): Assert about state of labels after move
+    // "foo" branch does not have the custom label
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().keySet())
+        .isEmpty();
+
+    // Move back to master and confirm that the custom label score is still there
+    move(changeId, "master");
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().keySet())
+        .containsExactly(testLabelA);
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().values())
+        .containsExactly((short) -1);
   }
 
   @Test
   public void moveNoDestinationBranchSpecified() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("destination branch is required");
-    move(r.getChangeId(), null);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("destination branch is required");
   }
 
   @Test
@@ -343,9 +374,9 @@
   public void moveCanBeDisabledByConfig() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("move changes endpoint is disabled");
-    move(r.getChangeId(), null);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("move changes endpoint is disabled");
   }
 
   private void move(int changeNum, String destination) throws RestApiException {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 649c7ae..7649316 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
index ca4288f6..1a3c10f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -15,21 +15,21 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -81,9 +81,9 @@
     setPrivateByDefault(project2, InheritableBoolean.TRUE);
 
     ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
@@ -125,22 +125,6 @@
     result.assertErrorStatus();
   }
 
-  @Test
-  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
-  public void pushDraftsWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
-    setPrivateByDefault(project2, InheritableBoolean.TRUE);
-
-    RevCommit initialHead = getRemoteHead(project2, "master");
-    TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
-    PushOneCommit.Result result =
-        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
-    result.assertErrorStatus();
-
-    testRepo.reset(initialHead);
-    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
-    result.assertErrorStatus();
-  }
-
   private void setPrivateByDefault(Project.NameKey proj, InheritableBoolean value)
       throws Exception {
     ConfigInput input = new ConfigInput();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 2ad4aca..6f519f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -19,8 +19,11 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -28,9 +31,6 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -39,7 +39,8 @@
 import org.junit.Test;
 
 public class SubmitByCherryPickIT extends AbstractSubmit {
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -47,12 +48,12 @@
   }
 
   @Test
-  public void submitWithCherryPickIfFastForwardPossible() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithCherryPickIfFastForwardPossible() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
 
     assertRefUpdatedEvents(initialHead, newHead);
@@ -60,17 +61,17 @@
   }
 
   @Test
-  public void submitWithCherryPick() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithCherryPick() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(1);
     assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 2, newHead);
@@ -85,17 +86,15 @@
   }
 
   @Test
-  public void changeMessageOnSubmit() throws Exception {
+  public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            "gerrit",
-            (newCommitMessage, original, mergeTip, destination) ->
-                newCommitMessage + "Custom: " + destination.get());
-    try {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                (newCommitMessage, original, mergeTip, destination) ->
+                    newCommitMessage + "Custom: " + destination.branch())) {
       submit(change.getChangeId());
-    } finally {
-      handle.remove();
     }
     testRepo.git().fetch().setRemote("origin").call();
     ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
@@ -107,20 +106,20 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
@@ -145,12 +144,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -162,7 +161,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
 
@@ -171,18 +170,18 @@
   }
 
   @Test
-  public void submitOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
@@ -199,12 +198,12 @@
   }
 
   @Test
-  public void submitOutOfOrder_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitOutOfOrder_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
@@ -217,7 +216,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
     assertNoSubmitter(change3.getChangeId(), 1);
 
@@ -226,8 +225,8 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -254,8 +253,8 @@
   }
 
   @Test
-  public void submitDependentNonConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitDependentNonConflictingChangesOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -264,11 +263,11 @@
 
     // Submit succeeds; change2 is successfully cherry-picked onto head.
     submit(change2.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     // Submit succeeds; change is successfully cherry-picked onto head
     // (which was change2's cherry-pick).
     submit(change.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     // change is the new tip.
     List<RevCommit> log = getRemoteLog();
@@ -290,8 +289,8 @@
   }
 
   @Test
-  public void submitDependentConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitDependentConflictingChangesOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b1");
@@ -322,8 +321,8 @@
   }
 
   @Test
-  public void submitSubsetOfDependentChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitSubsetOfDependentChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -334,7 +333,7 @@
     // related to change 3 by topic or ancestor (due to cherrypicking!)
     approve(change2.getChangeId());
     submit(change3.getChangeId());
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
 
     assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
@@ -345,8 +344,8 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitIdenticalTree() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
 
@@ -354,12 +353,13 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
 
     submit(change2.getChangeId(), new SubmitInput(), null, null);
 
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
 
     ChangeInfo info2 = get(change2.getChangeId(), MESSAGES);
     assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 974180c..670cff2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -16,19 +16,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 public class SubmitByFastForwardIT extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -36,11 +40,11 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
@@ -50,8 +54,8 @@
   }
 
   @Test
-  public void submitMultipleChangesWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -64,7 +68,7 @@
     approve(id2);
     submit(id3);
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
     assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
     assertSubmitter(change.getChangeId(), 1);
@@ -82,12 +86,12 @@
   }
 
   @Test
-  public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitTwoChangesWithFastForward_missingDependency() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
-    Change.Id id1 = change1.getPatchSetId().getParentKey();
+    Change.Id id1 = change1.getPatchSetId().changeId();
     submitWithConflict(
         change2.getChangeId(),
         "Failed to submit 2 changes due to the following problems:\n"
@@ -95,19 +99,19 @@
             + id1
             + ": needs Code-Review");
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
 
   @Test
-  public void submitFastForwardNotPossible_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitFastForwardNotPossible_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
 
@@ -128,7 +132,8 @@
             + ": Project policy requires "
             + "all submissions to be a fast-forward. Please rebase the change "
             + "locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
@@ -136,11 +141,15 @@
   }
 
   @Test
-  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitSameCommitsAsInExperimentalBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
-    grant(project, "refs/heads/*", Permission.CREATE);
-    grant(project, "refs/heads/experimental", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/heads/experimental").group(adminGroupUuid()))
+        .update();
 
     RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
@@ -153,9 +162,9 @@
         .isEqualTo(c1.getId());
 
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
-    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
+    assertThat(projectOperations.project(project).getHead("master").getId()).isEqualTo(c1.getId());
     assertSubmitter(id1, 1);
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index 9bc5a2f..f80bdca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -17,11 +17,14 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByMergeAlwaysIT extends AbstractSubmitByMerge {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -29,11 +32,11 @@
   }
 
   @Test
-  public void submitWithMergeIfFastForwardPossible() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMergeIfFastForwardPossible() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit.getParentCount()).isEqualTo(2);
     assertThat(headAfterSubmit.getParent(0)).isEqualTo(initialHead);
     assertThat(headAfterSubmit.getParent(1)).isEqualTo(change.getCommit());
@@ -46,8 +49,8 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Submit a change so that the remote head advances
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5ebcd85..b259d90 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -16,14 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+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.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -33,9 +41,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.InputStream;
@@ -63,11 +68,11 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
@@ -79,8 +84,8 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -136,13 +141,13 @@
   }
 
   @Test
-  public void submitChangesAcrossRepos() throws Exception {
+  public void submitChangesAcrossRepos() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     Project.NameKey p3 = projectOperations.newProject().create();
 
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
@@ -180,7 +185,7 @@
     approve(change3.getChangeId());
 
     // get a preview before submitting:
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -196,24 +201,24 @@
       // check that the preview matched what happened:
       assertThat(preview).hasSize(3);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
       assertTrees(p1, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
       assertTrees(p2, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
       assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
       assertThat(preview).hasSize(1);
-      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
+      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
   @Test
-  public void submitChangesAcrossReposBlocked() throws Exception {
+  public void submitChangesAcrossReposBlocked() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     Project.NameKey p3 = projectOperations.newProject().create();
@@ -222,9 +227,9 @@
     TestRepository<?> repo2 = cloneProject(p2);
     TestRepository<?> repo3 = cloneProject(p3);
 
-    RevCommit initialHead1 = getRemoteHead(p1, "master");
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead1 = projectOperations.project(p1).getHead("master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     PushOneCommit.Result change1a =
         createChange(
@@ -277,15 +282,12 @@
               + "and upload the rebased commit for review.";
 
       // Get a preview before submitting:
-      try (BinaryResult r = gApi.changes().id(change1b.getChangeId()).current().submitPreview()) {
-        // We cannot just use the ExpectedException infrastructure as provided
-        // by AbstractDaemonTest, as then we'd stop early and not test the
-        // actual submit.
+      RestApiException thrown =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
+      assertThat(thrown.getMessage()).isEqualTo(msg);
 
-        fail("expected failure");
-      } catch (RestApiException e) {
-        assertThat(e.getMessage()).isEqualTo(msg);
-      }
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -313,13 +315,13 @@
   }
 
   @Test
-  public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -362,12 +364,12 @@
   }
 
   @Test
-  public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -395,7 +397,7 @@
 
     Project.NameKey p3 = projectOperations.newProject().create();
     TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit repo3Head = getRemoteHead(p3, "master");
+    RevCommit repo3Head = projectOperations.project(p3).getHead("master");
     PushOneCommit.Result change3b =
         createChange(
             repo3,
@@ -435,8 +437,8 @@
   }
 
   @Test
-  public void gerritWorkflow() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void gerritWorkflow() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // We'll setup a master and a stable branch.
     // Then we create a change to be applied to master, which is
@@ -462,8 +464,8 @@
     gApi.changes().id(cherryId).current().submit();
 
     // Create the merge locally
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
     testRepo.git().fetch().call();
     testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
     testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
@@ -493,7 +495,7 @@
   }
 
   @Test
-  public void openChangeForTargetBranchPreventsMerge() throws Exception {
+  public void openChangeForTargetBranchPreventsMerge() throws Throwable {
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Propose a change for master, but leave it open for master!
@@ -517,7 +519,7 @@
         change3.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
-            + change3.getPatchSetId().getParentKey().get()
+            + change3.getPatchSetId().changeId().get()
             + ": Depends on change that was not submitted."
             + " Commit "
             + change3.getCommit().name()
@@ -532,7 +534,7 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Exception {
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
@@ -574,7 +576,7 @@
   }
 
   @Test
-  public void dependencyOnDeletedChangePreventsMerge() throws Exception {
+  public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
@@ -608,9 +610,13 @@
   }
 
   @Test
-  public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, REGISTERED_USERS, false);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+  public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Create a change
     PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -624,23 +630,26 @@
 
     // Move the first change to a destination branch that is non-visible to user so that user cannot
     // this change anymore.
-    Branch.NameKey secretBranch = new Branch.NameKey(project, "secretBranch");
+    BranchNameKey secretBranch = BranchNameKey.create(project, "secretBranch");
     gApi.projects()
-        .name(secretBranch.getParentKey().get())
-        .branch(secretBranch.get())
+        .name(secretBranch.project().get())
+        .branch(secretBranch.branch())
         .create(new BranchInput());
-    gApi.changes().id(changeResult.getChangeId()).move(secretBranch.get());
-    block(secretBranch.get(), "read", ANONYMOUS_USERS);
+    gApi.changes().id(changeResult.getChangeId()).move(secretBranch.branch());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref(secretBranch.branch()).group(ANONYMOUS_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
 
     // Verify that user cannot see the first change.
-    try {
-      gApi.changes().id(changeResult.getChangeId()).get();
-      fail("expected failure");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).isEqualTo("Not found: " + changeResult.getChangeId());
-    }
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeResult.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + changeResult.getChangeId());
 
     // Submit is expected to fail.
     submitWithConflict(
@@ -663,9 +672,13 @@
   }
 
   @Test
-  public void dependencyOnHiddenChangePreventsMerge() throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, REGISTERED_USERS, false);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+  public void dependencyOnHiddenChangePreventsMerge() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Create a change
     PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -684,30 +697,29 @@
     requestScopeOperations.setApiUser(user.id());
 
     // Verify that user cannot see the first change.
-    try {
-      gApi.changes().id(changeResult.getChangeId()).get();
-      fail("expected failure");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).isEqualTo("Not found: " + changeResult.getChangeId());
-    }
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeResult.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + changeResult.getChangeId());
 
     // Submit is expected to fail.
-    try {
-      gApi.changes().id(change2Result.getChangeId()).current().submit();
-      fail("expected failure");
-    } catch (AuthException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "A change to be submitted with "
-                  + change2Result.getChange().getId().id
-                  + " is not visible");
-    }
+    AuthException thrown2 =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(change2Result.getChangeId()).current().submit());
+    assertThat(thrown2)
+        .hasMessageThat()
+        .isEqualTo(
+            "A change to be submitted with "
+                + change2Result.getChange().getId().get()
+                + " is not visible");
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
 
   @Test
-  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Exception {
+  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Throwable {
     // Construct a topic where a change included by topic depends on a private change that is not
     // visible to the submitting user
     // (c1) --- topic --- (c2b)
@@ -718,10 +730,18 @@
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
 
-    grantLabel("Code-Review", -2, 2, p1, "refs/heads/*", false, REGISTERED_USERS, false);
-    grant(p1, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
-    grantLabel("Code-Review", -2, 2, p2, "refs/heads/*", false, REGISTERED_USERS, false);
-    grant(p2, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(p1)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(p2)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
@@ -744,30 +764,27 @@
     requestScopeOperations.setApiUser(user.id());
 
     // Verify that user cannot see change2a
-    try {
-      gApi.changes().id(change2a.getChangeId()).get();
-      fail("expected failure");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).isEqualTo("Not found: " + change2a.getChangeId());
-    }
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(change2a.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + change2a.getChangeId());
 
     // Submit is expected to fail.
-    try {
-      gApi.changes().id(change1.getChangeId()).current().submit();
-      fail("expected failure");
-    } catch (AuthException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "A change to be submitted with "
-                  + change1.getChange().getId().id
-                  + " is not visible");
-    }
+    AuthException thrown2 =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change1.getChangeId()).current().submit());
+    assertThat(thrown2)
+        .hasMessageThat()
+        .isEqualTo(
+            "A change to be submitted with "
+                + change1.getChange().getId().get()
+                + " is not visible");
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
 
   @Test
-  public void testPreviewSubmitTgz() throws Exception {
+  public void testPreviewSubmitTgz() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
 
     TestRepository<?> repo1 = cloneProject(p1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index eb8dea5..388c4f4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -15,35 +15,35 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import java.util.ArrayDeque;
-import java.util.Deque;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   @Inject private DynamicItem<UrlFormatter> urlFormatter;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -52,12 +52,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithPossibleFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+  public void submitWithPossibleFastForward() throws Throwable {
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isNotEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
@@ -72,7 +72,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void alwaysAddFooters() throws Exception {
+  public void alwaysAddFooters() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
@@ -89,21 +89,22 @@
   }
 
   @Test
-  public void rebaseInvokesChangeMessageModifiers() throws Exception {
+  public void rebaseInvokesChangeMessageModifiers() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> msg + "This-change-before-rebase: " + orig.name() + "\n";
     ChangeMessageModifier modifier2 =
         (msg, orig, tip, dest) -> msg + "Previous-step-tip: " + tip.name() + "\n";
     ChangeMessageModifier modifier3 =
-        (msg, orig, tip, dest) -> msg + "Dest: " + dest.getShortName() + "\n";
+        (msg, orig, tip, dest) -> msg + "Dest: " + dest.shortName() + "\n";
 
-    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2, modifier3)) {
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(modifier1).add(modifier2).add(modifier3)) {
       ImmutableList<PushOneCommit.Result> changes = submitWithRebase(admin);
       ChangeData cd1 = changes.get(0).getChange();
       ChangeData cd2 = changes.get(1).getChange();
       assertThat(cd2.patchSets()).hasSize(2);
-      String change1CurrentCommit = cd1.currentPatchSet().getRevision().get();
-      String change2Ps1Commit = cd2.patchSet(new PatchSet.Id(cd2.getId(), 1)).getRevision().get();
+      String change1CurrentCommit = cd1.currentPatchSet().commitId().name();
+      String change2Ps1Commit = cd2.patchSet(PatchSet.id(cd2.getId(), 1)).commitId().name();
 
       assertThat(gApi.changes().id(cd2.getId().get()).revision(2).commit(false).message)
           .isEqualTo(
@@ -120,65 +121,52 @@
   }
 
   @Test
-  public void failingChangeMessageModifierShortCircuits() throws Exception {
+  public void failingChangeMessageModifierShortCircuits() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> {
           throw new IllegalStateException("boom");
         };
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
-    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2)) {
-      try {
-        submitWithRebase();
-        assert_().fail("expected ResourceConflictException");
-      } catch (ResourceConflictException e) {
-        Throwable cause = Throwables.getRootCause(e);
-        assertThat(cause).isInstanceOf(RuntimeException.class);
-        assertThat(cause).hasMessageThat().isEqualTo("boom");
-      }
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      Throwable cause = Throwables.getRootCause(thrown);
+      assertThat(cause).isInstanceOf(RuntimeException.class);
+      assertThat(cause).hasMessageThat().isEqualTo("boom");
     }
   }
 
   @Test
-  public void changeMessageModifierReturningNullShortCircuits() throws Exception {
+  public void changeMessageModifierReturningNullShortCircuits() throws Throwable {
     ChangeMessageModifier modifier1 = (msg, orig, tip, dest) -> null;
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
-    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2)) {
-      try {
-        submitWithRebase();
-        assert_().fail("expected ResourceConflictException");
-      } catch (ResourceConflictException e) {
-        Throwable cause = Throwables.getRootCause(e);
-        assertThat(cause).isInstanceOf(RuntimeException.class);
-        assertThat(cause)
-            .hasMessageThat()
-            .isEqualTo(
-                modifier1.getClass().getName()
-                    + ".onSubmit from plugin modifier-1 returned null instead of new commit"
-                    + " message");
-      }
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(modifier1, "modifier-1")
+            .add(modifier2, "modifier-2")) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      Throwable cause = Throwables.getRootCause(thrown);
+      assertThat(cause).isInstanceOf(RuntimeException.class);
+      assertThat(cause)
+          .hasMessageThat()
+          .isEqualTo(
+              modifier1.getClass().getName()
+                  + ".onSubmit from plugin modifier-1 returned null instead of new commit"
+                  + " message");
     }
   }
 
-  private AutoCloseable installChangeMessageModifiers(ChangeMessageModifier... modifiers) {
-    Deque<RegistrationHandle> handles = new ArrayDeque<>(modifiers.length);
-    for (int i = 0; i < modifiers.length; i++) {
-      handles.push(changeMessageModifiers.add("modifier-" + (i + 1), modifiers[i]));
-    }
-    return () -> {
-      while (!handles.isEmpty()) {
-        handles.pop().remove();
-      }
-    };
-  }
-
-  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Throwable {
     RevCommit c = getCurrentCommit(change);
     assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
     assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
     assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
   }
 
-  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Exception {
+  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Throwable {
     testRepo.git().fetch().setRemote("origin").call();
     ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
     RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 7bb31a0..01b58ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -18,12 +18,15 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByRebaseIfNecessaryIT extends AbstractSubmitByRebase {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -32,11 +35,11 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
@@ -50,20 +53,20 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 87fc9f6..73f10e5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.Submit;
@@ -186,8 +186,8 @@
     String project2Name = name("Project2");
     gApi.projects().create(project1Name);
     gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+    TestRepository<InMemoryRepository> project1 = cloneProject(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     PushOneCommit.Result a = createChange(project1, "A");
     PushOneCommit.Result b =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 9bbe1dd..8e0042c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -16,7 +16,12 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 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.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -29,14 +34,17 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Set;
@@ -130,6 +138,48 @@
   }
 
   @Test
+  @GerritConfig(name = "index.maxTerms", value = "10")
+  public void suggestReviewersTooManyQueryTerms() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Do a query which doesn't exceed index.maxTerms succeeds (add only 9 terms, since on
+    // 'inactive:1' term is implicitly added) and assert that a result is returned
+    StringBuilder query = new StringBuilder();
+    for (int i = 1; i <= 9; i++) {
+      query.append(name("u")).append(" ");
+    }
+    assertThat(suggestReviewers(changeId, query.toString())).isNotEmpty();
+
+    // Do a query which exceed index.maxTerms succeeds (10 terms plus 'inactive:1' term which is
+    // implicitly added).
+    query.append(name("u"));
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> suggestReviewers(changeId, query.toString()));
+    assertThat(exception).hasMessageThat().isEqualTo("too many terms in query");
+  }
+
+  @Test
+  public void suggestReviewersWithExcludeGroups() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // by default groups are included
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("user"));
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
+
+    // exclude groups
+    reviewers =
+        gApi.changes().id(changeId).suggestReviewers(name("user")).excludeGroups(true).get();
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of());
+
+    // explicitly include groups
+    reviewers =
+        gApi.changes().id(changeId).suggestReviewers(name("user")).excludeGroups(false).get();
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
+  }
+
+  @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void suggestReviewersSameGroupVisibility() throws Exception {
     String changeId = createChange().getChangeId();
@@ -160,8 +210,12 @@
     List<SuggestedReviewerInfo> reviewers;
 
     requestScopeOperations.setApiUser(user3.id());
-    block("refs/*", "read", ANONYMOUS_USERS);
-    allow("refs/*", "read", group1);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(group1))
+        .update();
     reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).isEmpty();
   }
@@ -178,7 +232,10 @@
 
     // Clear cached group info.
     requestScopeOperations.setApiUser(user1.id());
-    allowGlobalCapabilities(group1, GlobalCapability.VIEW_ALL_ACCOUNTS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(group1))
+        .update();
     reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
     assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
@@ -376,135 +433,119 @@
   }
 
   @Test
-  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
-  public void reviewerRanking() throws Exception {
-    // Assert that user are ranked by the number of times they have applied a
-    // a label to a change (highest), added comments (medium) or owned a
-    // change (low).
-    String fullName = "Primum Finalis";
-    TestAccount userWhoOwns = user("customuser1", fullName);
-    TestAccount reviewer1 = user("customuser2", fullName);
-    TestAccount reviewer2 = user("customuser3", fullName);
-    TestAccount userWhoComments = user("customuser4", fullName);
-    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
-
-    // Create a change as userWhoOwns and add some reviews
-    requestScopeOperations.setApiUser(userWhoOwns.id());
-    String changeId1 = createChangeFromApi();
-
-    requestScopeOperations.setApiUser(reviewer1.id());
-    reviewChange(changeId1);
-
-    requestScopeOperations.setApiUser(user1.id());
-    String changeId2 = createChangeFromApi();
-
-    requestScopeOperations.setApiUser(reviewer1.id());
-    reviewChange(changeId2);
-
-    requestScopeOperations.setApiUser(reviewer2.id());
-    reviewChange(changeId2);
-
-    // Create a comment as a different user
-    requestScopeOperations.setApiUser(userWhoComments.id());
-    ReviewInput ri = new ReviewInput();
-    ri.message = "Test";
-    gApi.changes().id(changeId1).revision(1).review(ri);
-
-    // Create a change as a new user to assert that we receive the correct
-    // ranking
-
-    requestScopeOperations.setApiUser(userWhoLooksForSuggestions.id());
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(
-            reviewer1.id().get(),
-            reviewer2.id().get(),
-            userWhoOwns.id().get(),
-            userWhoComments.id().get())
-        .inOrder();
-  }
-
-  @Test
-  public void reviewerRankingProjectIsolation() throws Exception {
-    // Create new project
-    Project.NameKey newProject = projectOperations.newProject().create();
-
-    // Create users who review changes in both the default and the new project
-    String fullName = "Primum Finalis";
-    TestAccount userWhoOwns = user("customuser1", fullName);
-    TestAccount reviewer1 = user("customuser2", fullName);
-    TestAccount reviewer2 = user("customuser3", fullName);
-
-    requestScopeOperations.setApiUser(userWhoOwns.id());
-    String changeId1 = createChangeFromApi();
-
-    requestScopeOperations.setApiUser(reviewer1.id());
-    reviewChange(changeId1);
-
-    requestScopeOperations.setApiUser(userWhoOwns.id());
-    String changeId2 = createChangeFromApi(newProject);
-
-    requestScopeOperations.setApiUser(reviewer2.id());
-    reviewChange(changeId2);
-
-    requestScopeOperations.setApiUser(userWhoOwns.id());
-    String changeId3 = createChangeFromApi(newProject);
-
-    requestScopeOperations.setApiUser(reviewer2.id());
-    reviewChange(changeId3);
-
-    requestScopeOperations.setApiUser(userWhoOwns.id());
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
-
-    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
-    // in other projects
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer1.id().get(), reviewer2.id().get())
-        .inOrder();
-  }
-
-  @Test
   public void suggestNoInactiveAccounts() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeIdReviewed = createChangeFromApi();
+    String changeId = createChangeFromApi();
+
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
+    requestScopeOperations.setApiUser(foo1.id());
+    reviewChange(changeIdReviewed);
     assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
+    requestScopeOperations.setApiUser(foo2.id());
+    reviewChange(changeIdReviewed);
     assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
 
-    String changeId = createChange().getChangeId();
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().id(foo2.username()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
   }
 
   @Test
+  public void suggestNoExistingReviewers() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = createChangeFromApi();
+    String changeIdReviewed = createChangeFromApi();
+
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    requestScopeOperations.setApiUser(foo1.id());
+    reviewChange(changeIdReviewed);
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    requestScopeOperations.setApiUser(foo2.id());
+    reviewChange(changeIdReviewed);
+
+    assertReviewers(
+        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    gApi.changes().id(changeId).addReviewer(foo2.id().toString());
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
+  }
+
+  @Test
+  public void suggestCcAsReviewer() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = createChangeFromApi();
+    String changeIdReviewed = createChangeFromApi();
+
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    requestScopeOperations.setApiUser(foo1.id());
+    reviewChange(changeIdReviewed);
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    requestScopeOperations.setApiUser(foo2.id());
+    reviewChange(changeIdReviewed);
+
+    assertReviewers(
+        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = foo2.id().toString();
+    reviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+    assertReviewers(
+        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+  }
+
+  @Test
+  public void suggestReviewerAsCc() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = createChangeFromApi();
+    String changeIdReviewed = createChangeFromApi();
+
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    requestScopeOperations.setApiUser(foo1.id());
+    reviewChange(changeIdReviewed);
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    requestScopeOperations.setApiUser(foo2.id());
+    reviewChange(changeIdReviewed);
+
+    assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = foo2.id().toString();
+    reviewerInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+    assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+  }
+
+  @Test
   public void suggestBySecondaryEmailWithModifyAccount() throws Exception {
     String secondaryEmail = "foo.secondary@example.com";
     TestAccount foo = createAccountWithSecondaryEmail("foo", secondaryEmail);
 
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
-    assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
-
-    reviewers = suggestReviewers(createChange().getChangeId(), "secondary", 4);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "secondary", 4);
     assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
   }
 
   @Test
   public void cannotSuggestBySecondaryEmailWithoutModifyAccount() throws Exception {
-    String secondaryEmail = "foo.secondary@example.com";
-    createAccountWithSecondaryEmail("foo", secondaryEmail);
-
+    // Test that even if the account exists, the result is still empty since
+    // it shouldn't match to that account based only on the secondary email.
+    createAccountWithSecondaryEmail("foo", "foo.secondary@example.com");
     requestScopeOperations.setApiUser(user.id());
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
-    assertThat(reviewers).isEmpty();
-
-    reviewers = suggestReviewers(createChange().getChangeId(), "secondary2", 4);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "secondary", 4);
     assertThat(reviewers).isEmpty();
   }
 
@@ -525,6 +566,24 @@
     assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails).isNull();
   }
 
+  @Test
+  public void suggestsPeopleWithNoReviewsWhenExplicitlyQueried() throws Exception {
+    TestAccount newTeamMember = accountCreator.create("newTeamMember");
+
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = createChangeFromApi();
+    String changeIdReviewed = createChangeFromApi();
+
+    TestAccount reviewer = accountCreator.create("newReviewer");
+    requestScopeOperations.setApiUser(reviewer.id());
+    reviewChange(changeIdReviewed);
+
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, "new", 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer.id().get(), newTeamMember.id().get())
+        .inOrder();
+  }
+
   private TestAccount createAccountWithSecondaryEmail(String name, String secondaryEmail)
       throws Exception {
     TestAccount foo = accountCreator.create(name(name), "foo.primary@example.com", "Foo");
@@ -545,6 +604,10 @@
     return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
   }
 
+  private List<SuggestedReviewerInfo> suggestCcs(String changeId, String query) throws Exception {
+    return gApi.changes().id(changeId).suggestCcs(query).get();
+  }
+
   private AccountGroup.UUID createGroupWithArbitraryMembers(int numMembers) {
     Set<Account.Id> members =
         IntStream.rangeClosed(1, numMembers)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
index 49692dd..525f5d5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
@@ -15,127 +15,194 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.junit.After;
-import org.junit.Before;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 public class WorkInProgressByDefaultIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Project.NameKey project1;
-  private Project.NameKey project2;
-
-  @Before
-  public void setUp() throws Exception {
-    project1 = projectOperations.newProject().create();
-    project2 = projectOperations.newProject().parent(project1).create();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    requestScopeOperations.setApiUser(admin.id());
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
-    prefs.workInProgressByDefault = false;
-    gApi.accounts().id(admin.id().get()).setPreferences(prefs);
-  }
-
   @Test
   public void createChangeWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
     ChangeInfo info =
-        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+        gApi.changes().create(new ChangeInput(project.get(), "master", "empty change")).get();
     assertThat(info.workInProgress).isNull();
   }
 
   @Test
   public void createChangeWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
-    setWorkInProgressByDefaultForProject(project2);
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
   }
 
   @Test
   public void createChangeWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
     setWorkInProgressByDefaultForUser();
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
   }
 
   @Test
   public void createChangeBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
-    setWorkInProgressByDefaultForProject(project2);
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     input.workInProgress = false;
     assertThat(gApi.changes().create(input).get().workInProgress).isNull();
   }
 
   @Test
   public void createChangeBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
     setWorkInProgressByDefaultForUser();
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     input.workInProgress = false;
     assertThat(gApi.changes().create(input).get().workInProgress).isNull();
   }
 
   @Test
   public void createChangeWithWorkInProgressByDefaultForProjectInherited() throws Exception {
-    setWorkInProgressByDefaultForProject(project1);
+    Project.NameKey parentProject = projectOperations.newProject().create();
+    Project.NameKey childProject = projectOperations.newProject().parent(parentProject).create();
+    setWorkInProgressByDefaultForProject(parentProject);
     ChangeInfo info =
-        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+        gApi.changes().create(new ChangeInput(childProject.get(), "master", "empty change")).get();
     assertThat(info.workInProgress).isTrue();
   }
 
   @Test
   public void pushWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
-    setWorkInProgressByDefaultForProject(project2);
-    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isTrue();
   }
 
   @Test
   public void pushWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
     setWorkInProgressByDefaultForUser();
-    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isTrue();
   }
 
   @Test
   public void pushBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
-    setWorkInProgressByDefaultForProject(project2);
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
     assertThat(
-            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+            createChange(project, "refs/for/master%ready").getChange().change().isWorkInProgress())
         .isFalse();
   }
 
   @Test
   public void pushBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
     setWorkInProgressByDefaultForUser();
     assertThat(
-            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+            createChange(project, "refs/for/master%ready").getChange().change().isWorkInProgress())
         .isFalse();
   }
 
   @Test
   public void pushWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
-    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isFalse();
+    Project.NameKey project = projectOperations.newProject().create();
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isFalse();
   }
 
   @Test
   public void pushWorkInProgressByDefaultForProjectInherited() throws Exception {
-    setWorkInProgressByDefaultForProject(project1);
-    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+    Project.NameKey parentProject = projectOperations.newProject().create();
+    Project.NameKey childProject = projectOperations.newProject().parent(parentProject).create();
+    setWorkInProgressByDefaultForProject(parentProject);
+    assertThat(createChange(childProject).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushNewPatchSetWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Create change.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project);
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    result.assertOkStatus();
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+
+    setWorkInProgressByDefaultForUser();
+
+    // Create new patch set on existing change, this shouldn't mark the change as WIP.
+    result = pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void pushNewPatchSetAndNewChangeAtOnceWithWorkInProgressByDefaultForUserEnabled()
+      throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Create change.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project);
+    RevCommit initialHead = getHead(testRepo.getRepository(), "HEAD");
+    RevCommit commit1a =
+        testRepo.commit().parent(initialHead).message("Change 1").insertChangeId().create();
+    String changeId1 = GitUtil.getChangeId(testRepo, commit1a).get();
+    testRepo.reset(commit1a);
+    PushResult result = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(result, "refs/for/master");
+    assertThat(gApi.changes().id(changeId1).get().workInProgress).isNull();
+
+    setWorkInProgressByDefaultForUser();
+
+    // Clone the repo again. The test connection keeps an AccountState internally, so we need to
+    // create a new connection after changing account properties.
+    PatchSet.Id ps1OfChange1 =
+        PatchSet.id(Change.id(gApi.changes().id(changeId1).get()._number), 1);
+    testRepo = cloneProject(project);
+    testRepo.git().fetch().setRefSpecs(RefNames.patchSetRef(ps1OfChange1) + ":c1").call();
+    testRepo.reset("c1");
+
+    // Create a new patch set on the existing change and in the same push create a new successor
+    // change.
+    RevCommit commit1b = testRepo.amend(commit1a).create();
+    testRepo.reset(commit1b);
+    RevCommit commit2 =
+        testRepo.commit().parent(commit1b).message("Change 2").insertChangeId().create();
+    String changeId2 = GitUtil.getChangeId(testRepo, commit2).get();
+    testRepo.reset(commit2);
+    result = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(result, "refs/for/master");
+
+    // Check that the existing change (changeId1) is not marked as WIP, but only the newly created
+    // change (changeId2).
+    assertThat(gApi.changes().id(changeId1).get().workInProgress).isNull();
+    assertThat(gApi.changes().id(changeId2).get().workInProgress).isTrue();
   }
 
   private void setWorkInProgressByDefaultForProject(Project.NameKey p) throws Exception {
@@ -145,9 +212,12 @@
   }
 
   private void setWorkInProgressByDefaultForUser() throws Exception {
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
+    GeneralPreferencesInfo prefs = new GeneralPreferencesInfo();
     prefs.workInProgressByDefault = true;
     gApi.accounts().id(admin.id().get()).setPreferences(prefs);
+    // Generate a new API scope. User preferences are stored in IdentifiedUser, so we need to flush
+    // that entity.
+    requestScopeOperations.resetCurrentApiUser();
   }
 
   private PushOneCommit.Result createChange(Project.NameKey p) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 7ef915b..daeb032 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.restapi.config.PostCaches;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import org.junit.Test;
 
 public class CacheOperationsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushAll() throws Exception {
@@ -124,8 +129,11 @@
 
   @Test
   public void flushWebSessions_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r =
           userRestSession.post(
@@ -138,8 +146,11 @@
               "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
           .assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index caecefa..a161ec4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -15,15 +15,20 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushCache() throws Exception {
@@ -65,8 +70,11 @@
 
   @Test
   public void flushWebSessionsCache_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
       r.assertOK();
@@ -74,8 +82,11 @@
 
       userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
index fa35d19..a3c1722 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -15,84 +15,90 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.restapi.config.IndexChanges;
 import com.google.inject.Inject;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 public class IndexChangesIT extends AbstractDaemonTest {
 
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-
-  private ChangeIndexedCounter changeIndexedCounter;
-  private RegistrationHandle changeIndexedCounterHandle;
-
-  @Before
-  public void addChangeIndexedCounter() {
-    changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
-  }
-
-  @After
-  public void removeChangeIndexedCounter() {
-    if (changeIndexedCounterHandle != null) {
-      changeIndexedCounterHandle.remove();
-    }
-  }
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void indexRequestFromNonAdminRejected() throws Exception {
-    String changeId = createChange().getChangeId();
-    IndexChanges.Input in = new IndexChanges.Input();
-    in.changes = ImmutableSet.of(changeId);
-    changeIndexedCounter.clear();
-    userRestSession.post("/config/server/index.changes", in).assertForbidden();
-    assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(0);
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      String changeId = createChange().getChangeId();
+      IndexChanges.Input in = new IndexChanges.Input();
+      in.changes = ImmutableSet.of(changeId);
+      changeIndexedCounter.clear();
+      userRestSession.post("/config/server/index.changes", in).assertForbidden();
+      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(0);
+    }
   }
 
   @Test
   public void indexVisibleChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    IndexChanges.Input in = new IndexChanges.Input();
-    in.changes = ImmutableSet.of(changeId);
-    changeIndexedCounter.clear();
-    adminRestSession.post("/config/server/index.changes", in).assertOK();
-    assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      String changeId = createChange().getChangeId();
+      IndexChanges.Input in = new IndexChanges.Input();
+      in.changes = ImmutableSet.of(changeId);
+      changeIndexedCounter.clear();
+      adminRestSession.post("/config/server/index.changes", in).assertOK();
+      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+    }
   }
 
   @Test
   public void indexNonVisibleChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    ChangeInfo changeInfo = info(changeId);
-    blockRead("refs/heads/master");
-    IndexChanges.Input in = new IndexChanges.Input();
-    changeIndexedCounter.clear();
-    in.changes = ImmutableSet.of(changeId);
-    adminRestSession.post("/config/server/index.changes", in).assertOK();
-    assertThat(changeIndexedCounter.getCount(changeInfo)).isEqualTo(1);
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      String changeId = createChange().getChangeId();
+      ChangeInfo changeInfo = info(changeId);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+      IndexChanges.Input in = new IndexChanges.Input();
+      changeIndexedCounter.clear();
+      in.changes = ImmutableSet.of(changeId);
+      adminRestSession.post("/config/server/index.changes", in).assertOK();
+      assertThat(changeIndexedCounter.getCount(changeInfo)).isEqualTo(1);
+    }
   }
 
   @Test
   public void indexMultipleChanges() throws Exception {
-    ImmutableSet.Builder<String> changeIds = ImmutableSet.builder();
-    for (int i = 0; i < 10; i++) {
-      changeIds.add(createChange().getChangeId());
-    }
-    IndexChanges.Input in = new IndexChanges.Input();
-    in.changes = changeIds.build();
-    changeIndexedCounter.clear();
-    adminRestSession.post("/config/server/index.changes", in).assertOK();
-    for (String changeId : in.changes) {
-      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      ImmutableSet.Builder<String> changeIds = ImmutableSet.builder();
+      for (int i = 0; i < 10; i++) {
+        changeIds.add(createChange().getChangeId());
+      }
+      IndexChanges.Input in = new IndexChanges.Input();
+      in.changes = changeIds.build();
+      changeIndexedCounter.clear();
+      adminRestSession.post("/config/server/index.changes", in).assertOK();
+      for (String changeId : in.changes) {
+        assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+      }
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 4a74018..1d87ca1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -168,6 +168,8 @@
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
+    assertThat(i.change.submitWholeTopic).isNull();
+    assertThat(i.change.excludeMergeableInChangeInfo).isNull();
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -190,4 +192,18 @@
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void serverConfigWithSubmitWholeTopic() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.change.submitWholeTopic).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.api.excludeMergeableInChangeInfo", value = "true")
+  public void serverConfigWithExcludeMergeableInChangeInfo() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.change.excludeMergeableInChangeInfo).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 9420304..d70d120 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -14,20 +14,24 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.createAnnotatedTag;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -48,12 +52,14 @@
     }
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   private RevCommit initialHead;
   private TagType tagType;
 
   @Before
   public void setUpTestEnvironment() throws Exception {
-    initialHead = getRemoteHead();
+    initialHead = projectOperations.project(project).getHead("master");
     tagType = getTagType();
     blockAnonymousRead();
   }
@@ -203,7 +209,11 @@
     }
 
     if (!newCommit) {
-      grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+          .update();
       pushHead(testRepo, "refs/for/master%submit");
     }
 
@@ -213,7 +223,7 @@
             ? pushHead(testRepo, tagRef, false, force)
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
     return tagName;
   }
 
@@ -221,30 +231,50 @@
     String tagRef = tagRef(tagName);
     PushResult r = deleteRef(testRepo, tagRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
   }
 
   private void allowTagCreation() throws Exception {
-    grant(project, "refs/tags/*", tagType.createPermission, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(tagType.createPermission).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowPushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowForcePushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void allowTagDeletion() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void removePushFromRefsTags() throws Exception {
-    removePermission(project, "refs/tags/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.PUSH).ref("refs/tags/*"))
+        .update();
   }
 
   private void commit(PersonIdent ident, String subject) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 72af075..91a10ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -15,10 +15,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -26,26 +31,23 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -70,9 +72,9 @@
 
   private static final String LABEL_CODE_REVIEW = "Code-Review";
 
-  @Inject private DynamicSet<FileHistoryWebLink> fileHistoryWebLinkDynamicSet;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private Project.NameKey newProjectName;
 
@@ -87,45 +89,45 @@
     assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
   }
 
+  private Registration newFileHistoryWebLink() {
+    FileHistoryWebLink weblink =
+        new FileHistoryWebLink() {
+          @Override
+          public WebLinkInfo getFileHistoryWebLink(
+              String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(weblink);
+  }
+
   @Test
   public void webLink() throws Exception {
-    RegistrationHandle handle =
-        fileHistoryWebLinkDynamicSet.add(
-            "gerrit",
-            (projectName, revision, fileName) ->
-                new WebLinkInfo("name", "imageURL", "http://view/" + projectName + "/" + fileName));
-    try {
+    try (Registration registration = newFileHistoryWebLink()) {
       ProjectAccessInfo info = pApi().access();
       assertThat(info.configWebLinks).hasSize(1);
       assertThat(info.configWebLinks.get(0).url)
           .isEqualTo("http://view/" + newProjectName + "/project.config");
-    } finally {
-      handle.remove();
     }
   }
 
   @Test
   public void webLinkNoRefsMetaConfig() throws Exception {
-    RegistrationHandle handle =
-        fileHistoryWebLinkDynamicSet.add(
-            "gerrit",
-            (projectName, revision, fileName) ->
-                new WebLinkInfo("name", "imageURL", "http://view/" + projectName + "/" + fileName));
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
+    try (Repository repo = repoManager.openRepository(newProjectName);
+        Registration registration = newFileHistoryWebLink()) {
       RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
       u.setForceUpdate(true);
       assertThat(u.delete()).isEqualTo(Result.FORCED);
 
       // This should not crash.
       pApi().access();
-    } finally {
-      handle.remove();
     }
   }
 
   @Test
   public void addAccessSection() throws Exception {
-    RevCommit initialHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
@@ -135,7 +137,7 @@
 
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
 
-    RevCommit updatedHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
   }
@@ -143,8 +145,7 @@
   @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    exception.expect(BadRequestException.class);
-    pApi().accessChange(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
   }
 
   @Test
@@ -169,7 +170,11 @@
 
   @Test
   public void createAccessChange() throws Exception {
-    allow(newProjectName, RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
     // User can see the branch
     requestScopeOperations.setApiUser(user.id());
     pApi().branch("refs/heads/master").get();
@@ -206,12 +211,7 @@
 
     // check that the change took effect.
     requestScopeOperations.setApiUser(user.id());
-    try {
-      BranchInfo info = pApi().branch("refs/heads/master").get();
-      fail("wanted failure, got " + newGson().toJson(info));
-    } catch (ResourceNotFoundException e) {
-      // OK.
-    }
+    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
 
     // Restore.
     accessInput.add.clear();
@@ -326,8 +326,7 @@
     pApi().access(accessInput);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -346,8 +345,7 @@
     accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -409,9 +407,8 @@
     accessInput.parent = newParentProjectName;
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("administrate server not permitted");
-    pApi().access(accessInput);
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
   }
 
   @Test
@@ -436,8 +433,8 @@
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -465,8 +462,7 @@
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    pApi().access(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
   }
 
   @Test
@@ -479,9 +475,9 @@
     accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    exception.expect(BadRequestException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -492,8 +488,8 @@
     accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -572,7 +568,7 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
 
     // Make permission change through API
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -591,16 +587,20 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
   }
 
   @Test
   public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     accessInput.parent = project.get();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(allUsers.get() + " must inherit from " + allProjects.get());
-    gApi.projects().name(allUsers.get()).access(accessInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
   }
 
   @Test
@@ -650,9 +650,9 @@
     String invalidRef = Constants.R_HEADS + "stable_*";
     accessInput.add.put(invalidRef, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid Name: " + invalidRef);
-    pApi().access(accessInput);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
   }
 
   @Test
@@ -664,9 +664,9 @@
     String invalidRef = Constants.R_HEADS + "stable_*";
     accessInput.add.put(invalidRef, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid Name: " + invalidRef);
-    pApi().accessChange(accessInput);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
   }
 
   private ProjectApi pApi() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 6e66aab..ca187a3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -1,9 +1,9 @@
 load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_project",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["rest"],
     deps = [
         ":project",
@@ -11,7 +11,7 @@
         ":refassert",
         "//lib/commons:lang",
     ],
-)
+) for f in glob(["*IT.java"])]
 
 java_library(
     name = "refassert",
@@ -31,8 +31,8 @@
         "ProjectAssert.java",
     ],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index 7667fc0..a2f976a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -20,12 +20,14 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
@@ -34,20 +36,19 @@
 
 public class CheckMergeabilityIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  @Inject private ProjectOperations projectOperations;
+
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "test");
-    gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
-        .create(new BranchInput());
+    branch = BranchNameKey.create(project, "test");
+    gApi.projects().name(branch.project().get()).branch(branch.branch()).create(new BranchInput());
   }
 
   @Test
   public void checkMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -82,7 +83,7 @@
 
   @Test
   public void checkUnMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -117,7 +118,7 @@
 
   @Test
   public void checkOursMergeStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -211,7 +212,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
     assertContentMerged("master", commitId.getName(), "recursive");
   }
@@ -237,7 +238,7 @@
 
   @Test
   public void checkInvalidStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 698f7e0..85d383e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -16,14 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -31,23 +39,20 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
 public class CreateBranchIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Branch.NameKey testBranch;
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
-    testBranch = new Branch.NameKey(project, "test");
+    testBranch = BranchNameKey.create(project, "test");
   }
 
   @Test
@@ -104,17 +109,25 @@
   @Test
   public void createMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
-    assertCreateSucceeds(new Branch.NameKey(project, metaRef));
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
+    assertCreateSucceeds(BranchNameKey.create(project, metaRef));
   }
 
   @Test
   public void createUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsUsers(new Account.Id(1))),
+        BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
         ResourceConflictException.class,
         "Not allowed to create user branch.");
@@ -122,10 +135,14 @@
 
   @Test
   public void createGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsGroups(new AccountGroup.UUID("foo"))),
+        BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
         ResourceConflictException.class,
         "Not allowed to create group branch.");
@@ -133,81 +150,85 @@
 
   @Test
   public void createWithRevision() throws Exception {
-    RevCommit revision = getRemoteHead(project, "master");
+    RevCommit revision = projectOperations.project(project).getHead("master");
 
     // update master so that points to a different revision than the revision on which we create the
     // new branch
     pushTo("refs/heads/master");
-    assertThat(getRemoteHead(project, "master")).isNotEqualTo(revision);
+    assertThat(projectOperations.project(project).getHead("master")).isNotEqualTo(revision);
 
     BranchInput input = new BranchInput();
     input.revision = revision.name();
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(revision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(revision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch())).isEqualTo(revision);
   }
 
   @Test
   public void createWithoutSpecifyingRevision() throws Exception {
     // If revision is not specified, the branch is created based on HEAD, which points to master.
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     BranchInput input = new BranchInput();
     input.revision = null;
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(expectedRevision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(expectedRevision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch()))
+        .isEqualTo(expectedRevision);
   }
 
   @Test
   public void createWithEmptyRevision() throws Exception {
     // If revision is not specified, the branch is created based on HEAD, which points to master.
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     BranchInput input = new BranchInput();
     input.revision = "";
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(expectedRevision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(expectedRevision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch()))
+        .isEqualTo(expectedRevision);
   }
 
   @Test
   public void createRevisionIsTrimmed() throws Exception {
-    RevCommit revision = getRemoteHead(project, "master");
+    RevCommit revision = projectOperations.project(project).getHead("master");
 
     BranchInput input = new BranchInput();
     input.revision = "\t" + revision.name();
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(revision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(revision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch())).isEqualTo(revision);
   }
 
   @Test
   public void createWithBranchNameAsRevision() throws Exception {
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     BranchInput input = new BranchInput();
     input.revision = "master";
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(expectedRevision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(expectedRevision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch()))
+        .isEqualTo(expectedRevision);
   }
 
   @Test
   public void createWithFullBranchNameAsRevision() throws Exception {
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     BranchInput input = new BranchInput();
     input.revision = "refs/heads/master";
     BranchInfo created = branch(testBranch).create(input).get();
-    assertThat(created.ref).isEqualTo(testBranch.get());
+    assertThat(created.ref).isEqualTo(testBranch.branch());
     assertThat(created.revision).isEqualTo(expectedRevision.name());
-    assertThat(getRemoteHead(project, testBranch.getShortName())).isEqualTo(expectedRevision);
+    assertThat(projectOperations.project(project).getHead(testBranch.branch()))
+        .isEqualTo(expectedRevision);
   }
 
   @Test
@@ -238,44 +259,51 @@
   }
 
   private void blockCreateReference() throws Exception {
-    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertCreateSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertCreateSucceeds(BranchNameKey branch) throws Exception {
     BranchInfo created = branch(branch).create(new BranchInput()).get();
-    assertThat(created.ref).isEqualTo(branch.get());
+    assertThat(created.ref).isEqualTo(branch.branch());
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch, Class<? extends RestApiException> errType, String errMsg)
+      BranchNameKey branch, Class<? extends RestApiException> errType, String errMsg)
       throws Exception {
     assertCreateFails(branch, null, errType, errMsg);
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch,
+      BranchNameKey branch,
       String revision,
       Class<? extends RestApiException> errType,
       String errMsg)
       throws Exception {
     BranchInput in = new BranchInput();
     in.revision = revision;
+    RestApiException thrown = assertThrows(errType, () -> branch(branch).create(in));
     if (errMsg != null) {
-      exception.expectMessage(errMsg);
+      assertThat(thrown).hasMessageThat().contains(errMsg);
     }
-    exception.expect(errType);
-    branch(branch).create(in);
   }
 
-  private void assertCreateFails(Branch.NameKey branch, Class<? extends RestApiException> errType)
+  private void assertCreateFails(BranchNameKey branch, Class<? extends RestApiException> errType)
       throws Exception {
     assertCreateFails(branch, errType, null);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 463532f..6b2baa7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -19,7 +19,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -30,8 +33,13 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -43,10 +51,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -72,6 +76,7 @@
 import org.junit.Test;
 
 public class CreateProjectIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -86,7 +91,7 @@
     // for more extensive coverage of the LabelTypeInfo.
     assertThat(p.labels).hasSize(1);
 
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -162,7 +167,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -175,7 +180,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -186,7 +191,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + "/").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -197,7 +202,7 @@
     String newProjectName = name("newProject/newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -216,7 +221,7 @@
     in.requireChangeId = InheritableBoolean.TRUE;
     ProjectInfo p = gApi.projects().create(in).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    Project project = projectCache.get(new Project.NameKey(newProjectName)).getProject();
+    Project project = projectCache.get(Project.nameKey(newProjectName)).getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
     assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
@@ -242,7 +247,7 @@
     in.name = childName;
     in.parent = parentName;
     gApi.projects().create(in);
-    Project project = projectCache.get(new Project.NameKey(childName)).getProject();
+    Project project = projectCache.get(Project.nameKey(childName)).getProject();
     assertThat(project.getParentName()).isEqualTo(in.parent);
   }
 
@@ -265,12 +270,12 @@
     in.owners.add(
         Integer.toString(
             groupCache
-                .get(new AccountGroup.NameKey("Administrators"))
+                .get(AccountGroup.nameKey("Administrators"))
                 .orElse(null)
                 .getId()
                 .get())); // by ID
     gApi.projects().create(in);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
     expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
     expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
@@ -323,7 +328,12 @@
 
   @Test
   public void createProjectWithCapability() throws Exception {
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
       requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
@@ -331,8 +341,12 @@
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
@@ -355,7 +369,12 @@
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
     Project parent = projectCache.get(allProjects).getProject();
     parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
       requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
@@ -364,8 +383,12 @@
       assertThat(p.name).isEqualTo(in.name);
     } finally {
       parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
@@ -447,13 +470,13 @@
   }
 
   private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
       assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
     }
   }
 
   private void assertEmptyCommit(String projectName, String... refs) throws Exception {
-    Project.NameKey projectKey = new Project.NameKey(projectName);
+    Project.NameKey projectKey = Project.nameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
         RevWalk rw = new RevWalk(repo);
         TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
@@ -469,12 +492,11 @@
 
   private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
       throws Exception {
-    exception.expect(errType);
-    gApi.projects().create(in);
+    assertThrows(errType, () -> gApi.projects().create(in));
   }
 
   private Optional<String> readProjectConfig(String projectName) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName));
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName));
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       RevWalk rw = tr.getRevWalk();
       Ref ref = repo.exactRef(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ccdc497..5636014 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -25,6 +27,8 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -32,8 +36,6 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
@@ -42,12 +44,12 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Branch.NameKey testBranch;
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
     project = projectOperations.newProject().create();
-    testBranch = new Branch.NameKey(project, "test");
+    testBranch = BranchNameKey.create(project, "test");
     branch(testBranch).create(new BranchInput());
   }
 
@@ -100,7 +102,7 @@
   @Test
   public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
     grantDelete();
-    String ref = testBranch.getShortName();
+    String ref = testBranch.shortName();
     assertThat(ref).doesNotMatch(R_HEADS);
     assertDeleteByRestSucceeds(testBranch, ref);
   }
@@ -108,14 +110,14 @@
   @Test
   public void deleteBranchByRestWithFullName() throws Exception {
     grantDelete();
-    assertDeleteByRestSucceeds(testBranch, testBranch.get());
+    assertDeleteByRestSucceeds(testBranch, testBranch.branch());
   }
 
   @Test
   public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
     grantDelete();
     RestResponse r =
-        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.branch());
     r.assertNotFound();
     branch(testBranch).get();
   }
@@ -123,10 +125,14 @@
   @Test
   public void deleteMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
 
-    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
+    BranchNameKey metaBranch = BranchNameKey.create(project, metaRef);
     branch(metaBranch).create(new BranchInput());
 
     grantDelete();
@@ -135,22 +141,36 @@
 
   @Test
   public void deleteUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete user branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsUsers(admin.id()))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> branch(BranchNameKey.create(allUsers, RefNames.refsUsers(admin.id()))).delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete user branch.");
   }
 
   @Test
   public void deleteGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete group branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroupUuid()))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                branch(BranchNameKey.create(allUsers, RefNames.refsGroups(adminGroupUuid())))
+                    .delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete group branch.");
   }
 
   @Test
@@ -158,7 +178,7 @@
     MethodNotAllowedException thrown =
         assertThrows(
             MethodNotAllowedException.class,
-            () -> branch(new Branch.NameKey(allUsers, RefNames.REFS_CONFIG)).delete());
+            () -> branch(BranchNameKey.create(allUsers, RefNames.REFS_CONFIG)).delete());
     assertThat(thrown).hasMessageThat().contains("not allowed to delete branch refs/meta/config");
   }
 
@@ -167,31 +187,47 @@
     MethodNotAllowedException thrown =
         assertThrows(
             MethodNotAllowedException.class,
-            () -> branch(new Branch.NameKey(allUsers, RefNames.HEAD)).delete());
+            () -> branch(BranchNameKey.create(allUsers, RefNames.HEAD)).delete());
     assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
+  private void assertDeleteByRestSucceeds(BranchNameKey branch, String ref) throws Exception {
     RestResponse r =
         userRestSession.delete(
             "/projects/"
@@ -199,24 +235,21 @@
                 + "/branches/"
                 + IdString.fromDecoded(ref).encoded());
     r.assertNoContent();
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertDeleteSucceeds(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isTrue();
     String branchRev = branch(branch).get().revision;
     branch(branch).delete();
     eventRecorder.assertRefUpdatedEvents(
-        project.get(), branch.get(), null, branchRev, branchRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+        project.get(), branch.branch(), null, branchRev, branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
+  private void assertDeleteForbidden(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: delete");
-    branch(branch).delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> branch(branch).delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 4d72123..ad90109 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -25,8 +26,10 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
@@ -34,7 +37,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.List;
@@ -48,12 +50,17 @@
   private static final ImmutableList<String> BRANCHES =
       ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
-    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
-    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     for (String name : BRANCHES) {
       project().branch(name).create(new BranchInput());
     }
@@ -77,12 +84,8 @@
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = branchToDelete;
     requestScopeOperations.setApiUser(user.id());
-    try {
-      project().deleteBranches(input);
-      fail("Expected AuthException");
-    } catch (AuthException e) {
-      assertThat(e).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
-    }
+    AuthException thrown = assertThrows(AuthException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
     requestScopeOperations.setApiUser(admin.id());
     assertBranches(BRANCHES);
   }
@@ -92,12 +95,9 @@
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = BRANCHES;
     requestScopeOperations.setApiUser(user.id());
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
     requestScopeOperations.setApiUser(admin.id());
     assertBranches(BRANCHES);
   }
@@ -108,14 +108,11 @@
     List<String> branches = Lists.newArrayList(BRANCHES);
     branches.add("refs/heads/does-not-exist");
     input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     assertBranchesDeleted(BRANCHES);
   }
 
@@ -127,40 +124,37 @@
     List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
     branches.addAll(BRANCHES);
     input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     assertBranchesDeleted(BRANCHES);
   }
 
   @Test
   public void missingInput() throws Exception {
     DeleteBranchesInput input = null;
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
   public void missingBranchList() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
   public void emptyBranchList() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = Lists.newArrayList();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
@@ -198,7 +192,7 @@
   private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String branch : branches) {
-      result.put(branch, getRemoteHead(project, branch));
+      result.put(branch, projectOperations.project(project).getHead(branch));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 07bb2b1..9770031 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -15,12 +15,16 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -35,6 +39,7 @@
 public class DeleteTagIT extends AbstractDaemonTest {
   private static final String TAG = "refs/tags/test";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -97,19 +102,35 @@
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/tags/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/tags/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/tags/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/tags/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private TagApi tag() throws Exception {
@@ -122,14 +143,12 @@
     String tagRev = tagInfo.revision;
     tag().delete();
     eventRecorder.assertRefUpdatedEvents(project.get(), TAG, null, tagRev, tagRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    tag().get();
+    assertThrows(ResourceNotFoundException.class, () -> tag().get());
   }
 
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: delete");
-    tag().delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> tag().delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
index fae9d00..46e2345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
@@ -23,6 +24,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
@@ -41,6 +43,7 @@
   private static final ImmutableList<String> TAGS =
       ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -66,12 +69,9 @@
     DeleteTagsInput input = new DeleteTagsInput();
     input.tags = TAGS;
     requestScopeOperations.setApiUser(user.id());
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
     requestScopeOperations.setApiUser(admin.id());
     assertTags(TAGS);
   }
@@ -82,14 +82,11 @@
     List<String> tags = Lists.newArrayList(TAGS);
     tags.add("refs/tags/does-not-exist");
     input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
     assertTagsDeleted();
   }
 
@@ -101,14 +98,11 @@
     List<String> tags = Lists.newArrayList("refs/tags/does-not-exist");
     tags.addAll(TAGS);
     input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
     assertTagsDeleted();
   }
 
@@ -128,7 +122,7 @@
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String tag : tags) {
       String ref = prefixRef(tag);
-      result.put(ref, getRemoteHead(project, ref));
+      result.put(ref, projectOperations.project(project).getHead(ref));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 63f41ad..a7f3174 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -15,25 +15,26 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "master");
+    branch = BranchNameKey.create(project, "master");
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
     revision(change).submit();
@@ -45,12 +46,12 @@
     assertThat(content.asString()).isEqualTo(PushOneCommit.FILE_CONTENT);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getNonExistingFile() throws Exception {
-    branch().file("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
index 48527af..0bdaad0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index d736578..8911163 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -69,8 +71,10 @@
   }
 
   private void assertChildNotFound(Project.NameKey parent, String child) throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(child);
-    gApi.projects().name(parent.get()).child(child).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(parent.get()).child(child).get());
+    assertThat(thrown).hasMessageThat().contains(child);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 18c706b..b18db81 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -15,14 +15,18 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -32,12 +36,14 @@
 import org.junit.Test;
 
 public class GetCommitIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
     repo = GitUtil.newTestRepository(repoManager.openRepository(project));
-    blockRead("refs/*");
+    blockRead();
   }
 
   @After
@@ -107,8 +113,17 @@
 
   @Test
   public void getOpenChange_NotFound() throws Exception {
+    // Need to unblock read to allow the push operation to succeed if not, when retrieving the
+    // advertised refs during
+    // the push, the client won't be sent the initial commit and will send it again as part of the
+    // change.
+    unblockRead();
+
     PushOneCommit.Result r = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     r.assertOkStatus();
+
+    // Re-blocking the read
+    blockRead();
     assertNotFound(r.getCommit());
   }
 
@@ -119,6 +134,14 @@
     }
   }
 
+  private void blockRead() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void assertNotFound(ObjectId id) throws Exception {
     userRestSession.get("/projects/" + project.get() + "/commits/" + id.name()).assertNotFound();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 989050c..e9aa589 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -54,8 +55,9 @@
     assertThat(p.name).isEqualTo(name);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getProjectNotExisting() throws Exception {
-    gApi.projects().name("does-not-exist").get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index ec1c708..91a2c4b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -15,36 +15,48 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class ListBranchesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void listBranchesOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("non-existing").branches().get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("non-existing").branches().get());
   }
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).branches().get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branches().get());
   }
 
   @Test
@@ -59,7 +71,7 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    String refsConfig = getRemoteHead(project, RefNames.REFS_CONFIG).name();
+    String refsConfig = projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name();
     assertRefs(
         ImmutableList.of(
             branch("HEAD", "master", false),
@@ -71,7 +83,11 @@
 
   @Test
   public void listBranchesSomeHidden() throws Exception {
-    blockRead("refs/heads/dev");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/dev").group(REGISTERED_USERS))
+        .update();
     String master = pushTo("refs/heads/master").getCommit().name();
     pushTo("refs/heads/dev");
     requestScopeOperations.setApiUser(user.id());
@@ -84,7 +100,11 @@
 
   @Test
   public void listBranchesHeadHidden() throws Exception {
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     pushTo("refs/heads/master");
     String dev = pushTo("refs/heads/dev").getCommit().name();
     requestScopeOperations.setApiUser(user.id());
@@ -96,7 +116,10 @@
   public void listBranchesUsingPagination() throws Exception {
     BranchInfo head = branch("HEAD", "master", false);
     BranchInfo refsConfig =
-        branch(RefNames.REFS_CONFIG, getRemoteHead(project, RefNames.REFS_CONFIG).name(), false);
+        branch(
+            RefNames.REFS_CONFIG,
+            projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+            false);
     BranchInfo master =
         branch("refs/heads/master", pushTo("refs/heads/master").getCommit().getName(), false);
     BranchInfo branch1 =
@@ -170,11 +193,6 @@
   }
 
   private void assertBadRequest(ListRefsRequest<BranchInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 7746820..7535dea 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.apache.commons.lang.RandomStringUtils;
 import org.junit.Test;
@@ -31,9 +33,11 @@
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("non-existing");
-    gApi.projects().name(name("non-existing")).child("children");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(name("non-existing")).child("children"));
+    assertThat(thrown).hasMessageThat().contains("non-existing");
   }
 
   @Test
@@ -47,7 +51,7 @@
     Project.NameKey child1_1 = projectOperations.newProject().parent(child1).create();
     Project.NameKey child1_2 = projectOperations.newProject().parent(child1).create();
 
-    assertThatNameList(gApi.projects().name(child1.get()).children()).isOrdered();
+    assertThatNameList(gApi.projects().name(child1.get()).children()).isInOrder();
     assertThatNameList(gApi.projects().name(child1.get()).children())
         .containsExactly(child1_1, child1_2);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 9dba8cf..29d3eb2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -31,6 +33,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.Projects.ListRequest;
@@ -39,9 +42,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.ListProjects;
 import com.google.gson.Gson;
 import com.google.gson.JsonObject;
@@ -65,7 +66,7 @@
     Project.NameKey someProject = projectOperations.newProject().create();
     assertThatNameList(gApi.projects().list().get())
         .containsExactly(allProjects, allUsers, project, someProject);
-    assertThatNameList(gApi.projects().list().get()).isOrdered();
+    assertThatNameList(gApi.projects().list().get()).isInOrder();
   }
 
   @Test
@@ -73,10 +74,11 @@
     requestScopeOperations.setApiUser(user.id());
     assertThatNameList(gApi.projects().list().get()).contains(project);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     assertThatNameList(gApi.projects().list().get()).doesNotContain(project);
   }
@@ -243,7 +245,7 @@
     int n = 5;
     assertThat(all).hasSize(n);
     assertThatNameList(gApi.projects().list().withPrefix(pre).withStart(n - 1).get())
-        .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
+        .containsExactly(Project.nameKey(Iterables.getLast(all).name));
   }
 
   @Test
@@ -337,11 +339,6 @@
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException expected) {
-      // Expected.
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index 3b5a3a4..3f583a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -21,10 +21,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.IterableSubject;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.List;
 import java.util.Set;
@@ -38,7 +38,7 @@
           .that(Url.decode(info.id))
           .isEqualTo(info.name);
     }
-    return assertThat(Iterables.transform(actual, p -> new Project.NameKey(p.name)));
+    return assertThat(Iterables.transform(actual, p -> Project.nameKey(p.name)));
   }
 
   public static void assertProjectInfo(Project project, ProjectInfo info) {
@@ -47,7 +47,7 @@
       assertThat(info.name).isEqualTo(project.getName());
     }
     assertThat(Url.decode(info.id)).isEqualTo(project.getName());
-    Project.NameKey parentName = project.getParent(new Project.NameKey("All-Projects"));
+    Project.NameKey parentName = project.getParent(Project.nameKey("All-Projects"));
     if (parentName != null) {
       assertThat(info.parent).isEqualTo(parentName.get());
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index bf2a534..76c30a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.Arrays;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
index b3e3d2f..a93fc0f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.api.projects.RefInfo;
@@ -38,10 +39,12 @@
   public static void assertRefInfo(RefInfo expected, RefInfo actual) {
     assertThat(actual.ref).isEqualTo(expected.ref);
     if (expected.revision != null) {
-      assertThat(actual.revision).named("revision of " + actual.ref).isEqualTo(expected.revision);
+      assertWithMessage("revision of " + actual.ref)
+          .that(actual.revision)
+          .isEqualTo(expected.revision);
     }
-    assertThat(toBoolean(actual.canDelete))
-        .named("can delete " + actual.ref)
+    assertWithMessage("can delete " + actual.ref)
+        .that(toBoolean(actual.canDelete))
         .isEqualTo(toBoolean(expected.canDelete));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index c945a2b..f5d2db4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.FluentIterable;
@@ -23,6 +26,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
@@ -62,6 +66,7 @@
           + "=XFeC\n"
           + "-----END PGP SIGNATURE-----";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -76,29 +81,39 @@
 
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tags().get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").tags().get());
   }
 
   @Test
   public void getTagOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tag("tag").get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("does-not-exist").tag("tag").get());
   }
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tags().get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name(project.get()).tags().get());
   }
 
   @Test
   public void getTagOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tag("tag").get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).tag("tag").get());
   }
 
   @Test
@@ -173,7 +188,11 @@
     assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
     assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
 
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
     tags = getTags().get();
     assertThat(tags).hasSize(1);
     assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
@@ -182,7 +201,7 @@
 
   @Test
   public void lightweightTag() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
+    grantTagPermissions();
 
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/heads/master");
@@ -214,7 +233,7 @@
 
   @Test
   public void annotatedTag() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    grantTagPermissions();
 
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/heads/master");
@@ -261,30 +280,38 @@
     assertThat(result.ref).isEqualTo(R_TAGS + "test");
 
     input.ref = "refs/tags/test";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
-    tag(input.ref).create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("tag \"" + R_TAGS + "test\" already exists");
   }
 
   @Test
   public void createTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: create");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("not permitted: create");
   }
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE_TAG).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
-    exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create annotated tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -292,9 +319,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = SIGNED_ANNOTATION;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Cannot create signed tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -302,9 +329,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("ref must match URL");
-    tag("TEST").create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag("TEST").create(input));
+    assertThat(thrown).hasMessageThat().contains("ref must match URL");
   }
 
   @Test
@@ -314,9 +341,9 @@
     TagInput input = new TagInput();
     input.ref = "refs/heads/test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"" + input.ref + "\"");
   }
 
   @Test
@@ -326,9 +353,9 @@
     TagInput input = new TagInput();
     input.ref = "//";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"refs/tags/\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"refs/tags/\"");
   }
 
   @Test
@@ -339,9 +366,9 @@
     input.ref = "test";
     input.revision = "abcdefg";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid base revision");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
   }
 
   @Test
@@ -349,7 +376,7 @@
     grantTagPermissions();
 
     // If revision is not specified, the tag is created based on HEAD, which points to master.
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     TagInput input = new TagInput();
     input.ref = "test";
@@ -365,7 +392,7 @@
     grantTagPermissions();
 
     // If revision is not specified, the tag is created based on HEAD, which points to master.
-    RevCommit expectedRevision = getRemoteHead(project, "master");
+    RevCommit expectedRevision = projectOperations.project(project).getHead("master");
 
     TagInput input = new TagInput();
     input.ref = "test";
@@ -380,7 +407,7 @@
   public void baseRevisionIsTrimmed() throws Exception {
     grantTagPermissions();
 
-    RevCommit revision = getRemoteHead(project, "master");
+    RevCommit revision = projectOperations.project(project).getHead("master");
 
     TagInput input = new TagInput();
     input.ref = "test";
@@ -428,19 +455,18 @@
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 
   private void grantTagPermissions() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
-    grant(project, R_TAGS + "", Permission.DELETE);
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.DELETE).ref(R_TAGS + "").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_SIGNED_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
index 52e72fe..f98fb45 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.util;
 
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth.assert_;
 import static org.apache.http.HttpStatus.SC_FORBIDDEN;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
@@ -76,7 +75,8 @@
         response = restSession.delete(uri);
         break;
       default:
-        assert_().fail(String.format("unsupported method: %s", restCall.httpMethod().name()));
+        assertWithMessage(String.format("unsupported method: %s", restCall.httpMethod().name()))
+            .fail();
         throw new IllegalStateException();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index fa1b467..e924143 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.acceptance.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -25,8 +26,8 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResolver;
@@ -81,26 +82,16 @@
   }
 
   private void checkBySelfFails() throws Exception {
-    Result result = resolveAsResult("self");
-    assertThat(result.asIdSet()).isEmpty();
-    assertThat(result.isSelf()).isTrue();
-    try {
-      result.asUnique();
-      assert_().fail("expected UnresolvableAccountException");
-    } catch (UnresolvableAccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
-      assertThat(e.isSelf()).isTrue();
-    }
-
-    result = resolveAsResult("me");
-    assertThat(result.asIdSet()).isEmpty();
-    assertThat(result.isSelf()).isTrue();
-    try {
-      result.asUnique();
-      assert_().fail("expected UnresolvableAccountException");
-    } catch (UnresolvableAccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
-      assertThat(e.isSelf()).isTrue();
+    for (String input : ImmutableList.of("self", "me")) {
+      Result result = resolveAsResult(input);
+      assertThat(result.asIdSet()).isEmpty();
+      assertThat(result.isSelf()).isTrue();
+      UnresolvableAccountException thrown =
+          assertThrows(UnresolvableAccountException.class, () -> result.asUnique());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(String.format("Resolving account '%s' requires login", input));
+      assertThat(thrown.isSelf()).isTrue();
     }
   }
 
@@ -123,7 +114,7 @@
     Account.Id idWithExistingIdAsFullname =
         accountOperations.newAccount().fullname(existingId.toString()).create();
 
-    Account.Id nonexistentId = new Account.Id(sequences.nextAccountId());
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
     accountOperations.newAccount().fullname(nonexistentId.toString()).create();
 
     assertThat(resolve(existingId)).containsExactly(existingId);
@@ -137,7 +128,7 @@
     Account.Id existingId = accountOperations.newAccount().fullname("Test User").create();
     accountOperations.newAccount().fullname(existingId.toString()).create();
 
-    Account.Id nonexistentId = new Account.Id(sequences.nextAccountId());
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
     accountOperations.newAccount().fullname("Any Name (" + nonexistentId + ")").create();
     accountOperations.newAccount().fullname(nonexistentId.toString()).create();
 
@@ -259,31 +250,28 @@
 
     assertThat(resolve(account.accountId())).containsExactly(id);
     for (String input : inputs) {
-      assertThat(resolve(input)).named("results for %s (active)", input).containsExactly(id);
+      assertWithMessage("results for %s (active)", input).that(resolve(input)).containsExactly(id);
     }
 
     gApi.accounts().id(id.get()).setActive(false);
     assertThat(resolve(account.accountId())).containsExactly(id);
     for (String input : inputs) {
       Result result = accountResolver.resolve(input);
-      assertThat(result.asIdSet()).named("results for %s (inactive)", input).isEmpty();
-      try {
-        result.asUnique();
-        assert_().fail("expected UnresolvableAccountException");
-      } catch (UnresolvableAccountException e) {
-        assertThat(e)
-            .hasMessageThat()
-            .isEqualTo(
-                "Account '"
-                    + input
-                    + "' only matches inactive accounts. To use an inactive account, retry"
-                    + " with one of the following exact account IDs:\n"
-                    + id
-                    + ": "
-                    + nameEmail);
-      }
-      assertThat(resolveByNameOrEmail(input))
-          .named("results by name or email for %s (inactive)", input)
+      assertWithMessage("results for %s (inactive)", input).that(result.asIdSet()).isEmpty();
+      UnresolvableAccountException thrown =
+          assertThrows(UnresolvableAccountException.class, () -> result.asUnique());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "Account '"
+                  + input
+                  + "' only matches inactive accounts. To use an inactive account, retry"
+                  + " with one of the following exact account IDs:\n"
+                  + id
+                  + ": "
+                  + nameEmail);
+      assertWithMessage("results by name or email for %s (inactive)", input)
+          .that(resolveByNameOrEmail(input))
           .isEmpty();
     }
   }
@@ -352,6 +340,6 @@
         accountsUpdateProvider
             .get()
             .update("Force set preferred email", id, (s, u) -> u.setPreferredEmail(email));
-    assertThat(result.map(a -> a.getAccount().getPreferredEmail())).hasValue(email);
+    assertThat(result.map(a -> a.account().preferredEmail())).hasValue(email);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 35fe48f..7ac803e 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -31,6 +31,9 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -45,9 +48,6 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -61,11 +61,11 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -98,8 +98,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    exception.expect(ResourceNotFoundException.class);
-    getPublishedComment(changeId, revId, "non-existing");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> getPublishedComment(changeId, revId, "non-existing"));
   }
 
   @Test
@@ -140,12 +141,11 @@
       addDraft(changeId, revId, c4);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
-      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
-          .containsExactly(c1, c2, c3, c4);
+      assertThat(result.get(path).stream().map(infoToDraft(path))).containsExactly(c1, c2, c3, c4);
 
       List<CommentInfo> list = getDraftCommentsAsList(changeId);
       assertThat(list).hasSize(4);
-      assertThat(Lists.transform(list, infoToDraft(path))).containsExactly(c1, c2, c3, c4);
+      assertThat(list.stream().map(infoToDraft(path))).containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -246,11 +246,10 @@
       revision(r).review(input);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file)))
-          .containsExactly(c1, c2, c3, c4);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3, c4);
 
       List<CommentInfo> list = getPublishedCommentsAsList(changeId);
-      assertThat(Lists.transform(list, infoToInput(file))).containsExactly(c1, c2, c3, c4);
+      assertThat(list.stream().map(infoToInput(file))).containsExactly(c1, c2, c3, c4);
     }
 
     // for the commit message comments on the auto-merge are not possible
@@ -268,10 +267,10 @@
       revision(r).review(input);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3);
 
       List<CommentInfo> list = getPublishedCommentsAsList(changeId);
-      assertThat(Lists.transform(list, infoToInput(file))).containsExactly(c1, c2, c3);
+      assertThat(list.stream().map(infoToInput(file))).containsExactly(c1, c2, c3);
     }
   }
 
@@ -282,9 +281,40 @@
     CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
     input.comments = new HashMap<>();
     input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
-    revision(r).review(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> revision(r).review(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
+  }
+
+  @Test
+  public void postCommentsReplacingDrafts() throws Exception {
+    String file = "file";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, "contents");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    DraftInput draft = newDraft(file, Side.REVISION, 0, "comment");
+    addDraft(changeId, revId, draft);
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    CommentInfo draftInfo = Iterables.getOnlyElement(drafts.get(draft.path));
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.KEEP;
+    reviewInput.message = "foo";
+    CommentInput comment = newComment(file, Side.REVISION, 0, "comment", false);
+    // Replace the existing draft.
+    comment.id = draftInfo.id;
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(comment.path, ImmutableList.of(comment));
+    revision(r).review(reviewInput);
+
+    // DraftHandling.KEEP is ignored on publishing a comment.
+    drafts = getDraftComments(changeId, revId);
+    assertThat(drafts).isEmpty();
   }
 
   @Test
@@ -350,12 +380,11 @@
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToInput(file)))
+    assertThat(actualComments.stream().map(infoToInput(file)))
         .containsExactlyElementsIn(expectedComments);
 
     List<CommentInfo> list = getPublishedCommentsAsList(changeId);
-    assertThat(Lists.transform(list, infoToInput(file)))
-        .containsExactlyElementsIn(expectedComments);
+    assertThat(list.stream().map(infoToInput(file))).containsExactlyElementsIn(expectedComments);
   }
 
   /**
@@ -434,7 +463,7 @@
     Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+    assertThat(actualComments.stream().map(infoToDraft(file)))
         .containsExactlyElementsIn(expectedDrafts);
   }
 
@@ -888,8 +917,9 @@
     DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input));
   }
 
   @Test
@@ -1047,20 +1077,6 @@
     assertThat(getChangeSortedComments(id.get())).hasSize(3);
   }
 
-  @Test
-  public void jsonCommentHasLegacyFormatFalse() throws Exception {
-    PushOneCommit.Result result = createChange();
-    Change.Id changeId = result.getChange().getId();
-    addComment(result.getChangeId(), "comment");
-
-    Collection<com.google.gerrit.reviewdb.client.Comment> comments =
-        notesFactory.createChecked(project, changeId).getComments().values();
-    assertThat(comments).hasSize(1);
-    com.google.gerrit.reviewdb.client.Comment comment = comments.iterator().next();
-    assertThat(comment.message).isEqualTo("comment");
-    assertThat(comment.legacyFormat).isFalse();
-  }
-
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
@@ -1101,17 +1117,16 @@
         RevCommit commitBefore = beforeDelete.get(i);
         RevCommit commitAfter = afterDelete.get(i);
 
-        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapBefore =
+        Map<String, com.google.gerrit.entities.Comment> commentMapBefore =
             DeleteCommentRewriter.getPublishedComments(
-                noteUtil, changeId, reader, NoteMap.read(reader, commitBefore));
-        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapAfter =
+                noteUtil, reader, NoteMap.read(reader, commitBefore));
+        Map<String, com.google.gerrit.entities.Comment> commentMapAfter =
             DeleteCommentRewriter.getPublishedComments(
-                noteUtil, changeId, reader, NoteMap.read(reader, commitAfter));
+                noteUtil, reader, NoteMap.read(reader, commitAfter));
 
         if (commentMapBefore.containsKey(targetCommentUuid)) {
           assertThat(commentMapAfter).containsKey(targetCommentUuid);
-          com.google.gerrit.reviewdb.client.Comment comment =
-              commentMapAfter.get(targetCommentUuid);
+          com.google.gerrit.entities.Comment comment = commentMapAfter.get(targetCommentUuid);
           assertThat(comment.message).isEqualTo(expectedMessage);
           comment.message = commentMapBefore.get(targetCommentUuid).message;
           commentMapAfter.put(targetCommentUuid, comment);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 7c375bd..1e2d1ba 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -25,14 +25,14 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -131,7 +131,7 @@
     assertProblems(
         notes,
         null,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
@@ -142,7 +142,7 @@
     PatchSet ps = insertMissingPatchSet(notes, rev);
     notes = reload(notes);
 
-    String refName = ps.getId().toRefName();
+    String refName = ps.id().toRefName();
     assertProblems(
         notes,
         new FixInput(),
@@ -153,8 +153,7 @@
   @Test
   public void patchSetRefMissing() throws Exception {
     ChangeNotes notes = insertChange();
-    serverSideTestRepo.update(
-        "refs/other/foo", ObjectId.fromString(psUtil.current(notes).getRevision().get()));
+    serverSideTestRepo.update("refs/other/foo", psUtil.current(notes).commitId());
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
@@ -164,15 +163,15 @@
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
-    serverSideTestRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    ObjectId commitId = psUtil.current(notes).commitId();
+    serverSideTestRepo.update("refs/other/foo", commitId);
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
     assertProblems(
         notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
-    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId().name())
-        .isEqualTo(rev);
+    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId())
+        .isEqualTo(commitId);
   }
 
   @Test
@@ -189,13 +188,13 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.get(notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps2.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
   }
 
   @Test
@@ -218,17 +217,17 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
-        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Ref missing: " + ps4.id().toRefName()),
         problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
-    assertThat(psUtil.get(notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps2.getId())).isNull();
-    assertThat(psUtil.get(notes, ps3.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps4.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
+    assertThat(psUtil.get(notes, ps3.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps4.id())).isNull();
   }
 
   @Test
@@ -245,7 +244,7 @@
             + "\n"
             + "Patch-set: 1\n"
             + "Branch: "
-            + c.getDest().get()
+            + c.getDest().branch()
             + "\n"
             + "Change-id: "
             + c.getKey().get()
@@ -265,7 +264,7 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem(
             "Object missing: patch set 1: " + rev,
             FIX_FAILED,
@@ -280,13 +279,13 @@
   public void duplicatePatchSetRevisions() throws Exception {
     ChangeNotes notes = insertChange();
     PatchSet ps1 = psUtil.current(notes);
-    String rev = ps1.getRevision().get();
 
-    notes =
-        incrementPatchSet(
-            notes, serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+    notes = incrementPatchSet(notes, serverSideTestRepo.getRevWalk().parseCommit(ps1.commitId()));
 
-    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+    assertProblems(
+        notes,
+        null,
+        problem("Multiple patch sets pointing to " + ps1.commitId().name() + ": [1, 2]"));
   }
 
   @Test
@@ -322,14 +321,13 @@
     }
     notes = reload(notes);
 
-    String rev = psUtil.current(notes).getRevision().get();
     ObjectId tip = getDestRef(notes);
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + psUtil.current(notes).commitId().name()
                 + ") is not merged into destination ref"
                 + " refs/heads/master ("
                 + tip.name()
@@ -339,40 +337,40 @@
   @Test
   public void newChangeIsMerged() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW"));
   }
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         new FixInput(),
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
@@ -385,10 +383,10 @@
   @Test
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
@@ -400,22 +398,22 @@
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev;
+    fix.expectMergedAs = commitId.name();
     assertProblems(
         notes,
         fix,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
@@ -428,9 +426,9 @@
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    serverSideTestRepo.branch(notes.getChange().getDest().get()).update(commit);
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other = serverSideTestRepo.commit().message(commit.getFullMessage()).create();
@@ -450,9 +448,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -481,9 +479,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -491,9 +489,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -529,9 +527,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -539,41 +537,43 @@
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(notes);
-    String rev1 = ps1.getRevision().get();
+    ObjectId commitId1 = psUtil.current(notes).commitId();
     notes = incrementPatchSet(notes);
     PatchSet ps2 = psUtil.current(notes);
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId1));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev1;
+    fix.expectMergedAs = commitId1.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commitId1.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Inserted as patch set 3"));
 
     notes = reload(notes);
-    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
+    PatchSet.Id psId3 = PatchSet.id(notes.getChangeId(), 3);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.getId(), psId3);
-    assertThat(psUtil.get(notes, psId3).getRevision().get()).isEqualTo(rev1);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.id(), psId3);
+    assertThat(psUtil.get(notes, psId3).commitId()).isEqualTo(commitId1);
   }
 
   @Test
@@ -582,47 +582,46 @@
     PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref so next ID in the database becomes 3.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
     notes = incrementPatchSet(notes);
     PatchSet ps3 = psUtil.current(notes);
-    assertThat(ps3.getId().get()).isEqualTo(3);
+    assertThat(ps3.id().get()).isEqualTo(3);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Inserted as patch set 4"));
 
     notes = reload(notes);
-    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
+    PatchSet.Id psId4 = PatchSet.id(notes.getChangeId(), 4);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet())
-        .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(notes, psId4).getRevision().get()).isEqualTo(rev2);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), ps3.id(), psId4);
+    assertThat(psUtil.get(notes, psId4).commitId()).isEqualTo(commit2);
   }
 
   @Test
@@ -631,24 +630,24 @@
     PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref with no patch set.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 1",
             FIXED,
@@ -657,17 +656,17 @@
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.getId(), psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(rev2);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), psId2);
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(commit2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
+    String dest = notes.getChange().getDest().branch();
     RevCommit parent = serverSideTestRepo.branch(dest).commit().message("parent").create();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
@@ -700,19 +699,19 @@
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
     ChangeNotes notes1 = insertChange();
-    PatchSet.Id psId1 = psUtil.current(notes1).getId();
-    String dest = notes1.getChange().getDest().get();
-    String rev = psUtil.current(notes1).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    PatchSet.Id psId1 = psUtil.current(notes1).id();
+    String dest = notes1.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes1).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     ChangeNotes notes2 = insertChange();
     notes2 = incrementPatchSet(notes2, commit);
-    PatchSet.Id psId2 = psUtil.current(notes2).getId();
+    PatchSet.Id psId2 = psUtil.current(notes2).id();
 
     ChangeNotes notes3 = insertChange();
     notes3 = incrementPatchSet(notes3, commit);
-    PatchSet.Id psId3 = psUtil.current(notes3).getId();
+    PatchSet.Id psId3 = psUtil.current(notes3).id();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
@@ -744,10 +743,10 @@
   }
 
   private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
-    Change.Id id = new Change.Id(sequences.nextChangeId());
+    Change.Id id = Change.id(sequences.nextChangeId());
     ChangeInserter ins;
     try (BatchUpdate bu = newUpdate(owner.id())) {
-      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+      RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
       bu.setNotify(NotifyResolver.Result.none());
       ins =
           changeInserterFactory
@@ -842,14 +841,14 @@
   private ObjectId getDestRef(ChangeNotes notes) throws Exception {
     return serverSideTestRepo
         .getRepository()
-        .exactRef(notes.getChange().getDest().get())
+        .exactRef(notes.getChange().getDest().branch())
         .getObjectId();
   }
 
   private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
-    final ObjectId oldId = getDestRef(notes);
-    final ObjectId newId = ObjectId.fromString(psUtil.current(notes).getRevision().get());
-    final String dest = notes.getChange().getDest().get();
+    ObjectId oldId = getDestRef(notes);
+    ObjectId newId = psUtil.current(notes).commitId();
+    String dest = notes.getChange().getDest().branch();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
       bu.addOp(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b9b7ab3..e369d1b 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -15,35 +15,36 @@
 package com.google.gerrit.acceptance.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -52,7 +53,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -64,11 +64,11 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
+@UseClockStep
+@UseTimezone(timezone = "US/Eastern")
 public class GetRelatedIT extends AbstractDaemonTest {
   private static final int MAX_TERMS = 10;
 
@@ -81,22 +81,9 @@
 
   @Inject private AccountOperations accountOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
   @Inject private IndexConfig indexConfig;
   @Inject private ChangesCollection changes;
 
@@ -129,14 +116,14 @@
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
+    String oldETag = changes.parse(ps1_1.changeId()).getETag();
 
     testRepo.reset(c2_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.getParentKey()).getETag()).isEqualTo(oldETag);
+    assertThat(changes.parse(ps1_1.changeId()).getETag()).isEqualTo(oldETag);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
       assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
@@ -498,7 +485,7 @@
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps2_edit = new PatchSet.Id(ch2.getId(), 0);
+    PatchSet.Id ps2_edit = PatchSet.id(ch2.getId(), 0);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
@@ -512,7 +499,7 @@
     assertRelated(
         ps2_edit,
         changeAndCommit(ps3_1, c3_1, 1),
-        changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
+        changeAndCommit(PatchSet.id(ch2.getId(), 0), editRev, 1),
         changeAndCommit(ps1_1, c1_1, 1));
   }
 
@@ -533,7 +520,7 @@
 
     // Pretend PS1,1 was pushed before the groups field was added.
     clearGroups(psId1_1);
-    indexer.index(changeDataFactory.create(project, psId1_1.getParentKey()));
+    indexer.index(changeDataFactory.create(project, psId1_1.changeId()));
 
     // PS1,1 has no groups, so disappeared from related changes.
     assertRelated(psId2_1);
@@ -568,7 +555,7 @@
 
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
     PatchSet.Id psId2_1 = getPatchSetId(c2_1);
-    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
+    PatchSet.Id psId2_2 = PatchSet.id(psId2_1.changeId(), psId2_1.get() + 1);
 
     assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
   }
@@ -611,11 +598,10 @@
 
     Account.Id accountId = accountOperations.newAccount().create();
     AccountGroup.UUID groupUuid = groupOperations.newGroup().addMember(accountId).create();
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      PermissionRule rule = Util.allow(u.getConfig(), GlobalCapability.QUERY_LIMIT, groupUuid);
-      rule.setRange(0, 2);
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.QUERY_LIMIT).group(groupUuid).range(0, 2))
+        .update();
     requestScopeOperations.setApiUser(accountId);
 
     assertRelated(lastPsId, expected);
@@ -651,13 +637,8 @@
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<RelatedChangeAndCommitInfo, String>() {
-          @Override
-          public boolean apply(
-              RelatedChangeAndCommitInfo relatedChangeAndCommitInfo, String status) {
-            return Objects.equals(relatedChangeAndCommitInfo.status, status);
-          }
-        },
+        (relatedChangeAndCommitInfo, status) ->
+            Objects.equals(relatedChangeAndCommitInfo.status, status),
         "has status");
   }
 
@@ -678,7 +659,7 @@
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
     RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
-    result._changeNumber = psId.getParentKey().get();
+    result._changeNumber = psId.changeId().get();
     result.commit = new CommitInfo();
     result.commit.commit = commitId.name();
     result._revisionNumber = psId.get();
@@ -690,12 +671,11 @@
   private void clearGroups(PatchSet.Id psId) throws Exception {
     try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
             public boolean updateChange(ChangeContext ctx) {
-              PatchSet ps = psUtil.get(ctx.getNotes(), psId);
-              psUtil.setGroups(ctx.getUpdate(psId), ps, ImmutableList.of());
+              ctx.getUpdate(psId).setGroups(ImmutableList.of());
               return true;
             }
           });
@@ -711,19 +691,19 @@
   private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
       throws Exception {
     List<RelatedChangeAndCommitInfo> actual =
-        gApi.changes().id(psId.getParentKey().get()).revision(psId.get()).related().changes;
-    assertThat(actual).named("related to " + psId).hasSize(expected.size());
+        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+    assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
       RelatedChangeAndCommitInfo a = actual.get(i);
       RelatedChangeAndCommitInfo e = expected.get(i);
-      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
-      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
+      assertWithMessage("project of " + name).that(a.project).isEqualTo(e.project);
+      assertWithMessage("change ID of " + name).that(a._changeNumber).isEqualTo(e._changeNumber);
       // Don't bother checking changeId; assume _changeNumber is sufficient.
-      assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
-      assertThat(a.commit.commit).named("commit of " + name).isEqualTo(e.commit.commit);
-      assertThat(a._currentRevisionNumber)
-          .named("current revision of " + name)
+      assertWithMessage("revision of " + name).that(a._revisionNumber).isEqualTo(e._revisionNumber);
+      assertWithMessage("commit of " + name).that(a.commit.commit).isEqualTo(e.commit.commit);
+      assertWithMessage("current revision of " + name)
+          .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
       assertThat(a.status).isEqualTo(e.status);
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index 42fdae4..b23f9a3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -25,9 +25,9 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.server.patch.IntraLineDiff;
 import com.google.gerrit.server.patch.IntraLineDiffArgs;
 import com.google.gerrit.server.patch.IntraLineDiffKey;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 389859c..445f787 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.EnumSet;
@@ -113,7 +113,7 @@
 
   @Test
   public void respectWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     // Create two independent commits and push.
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -135,7 +135,7 @@
 
   @Test
   public void anonymousWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
     pushHead(testRepo, "refs/for/master/" + name("topic"), false);
     String id1 = getChangeId(a);
@@ -157,7 +157,7 @@
 
   @Test
   public void topicChaining() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -185,7 +185,7 @@
 
   @Test
   public void respectTopicsOnAncestors() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 4f98dd0..8469fff 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -15,71 +15,48 @@
 package com.google.gerrit.acceptance.server.event;
 
 import static com.google.common.truth.Truth.assertThat;
+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.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
-import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CommentAddedEventIT extends AbstractDaemonTest {
 
-  @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
   private final LabelType pLabel =
-      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
-
-  private RegistrationHandle eventListenerRegistration;
-  private CommentAddedListener.Event lastCommentAddedEvent;
+      label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(pLabel.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      u.save();
-    }
-
-    eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
-  }
-
-  @After
-  public void cleanup() {
-    eventListenerRegistration.remove();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
   }
 
   private void saveLabelConfig() throws Exception {
@@ -90,16 +67,30 @@
     }
   }
 
+  private static class TestListener implements CommentAddedListener {
+    private CommentAddedListener.Event lastCommentAddedEvent;
+
+    @Override
+    public void onCommentAdded(Event event) {
+      lastCommentAddedEvent = event;
+    }
+
+    public CommentAddedListener.Event getLastCommentAddedEvent() {
+      assertThat(lastCommentAddedEvent).isNotNull();
+      return lastCommentAddedEvent;
+    }
+  }
+
   /* Need to lookup info for the label under test since there can be multiple
    * labels defined.  By default Gerrit already has a Code-Review label.
    */
-  private ApprovalValues getApprovalValues(LabelType label) {
+  private ApprovalValues getApprovalValues(LabelType label, TestListener listener) {
     ApprovalValues res = new ApprovalValues();
-    ApprovalInfo info = lastCommentAddedEvent.getApprovals().get(label.getName());
+    ApprovalInfo info = listener.getLastCommentAddedEvent().getApprovals().get(label.getName());
     if (info != null) {
       res.value = info.value;
     }
-    info = lastCommentAddedEvent.getOldApprovals().get(label.getName());
+    info = listener.getLastCommentAddedEvent().getOldApprovals().get(label.getName());
     if (info != null) {
       res.oldValue = info.value;
     }
@@ -110,15 +101,18 @@
   public void newChangeWithVote() throws Exception {
     saveLabelConfig();
 
-    // push a new change with -1 vote
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().label(label.getName(), (short) -1);
-    revision(r).review(reviewInput);
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      // push a new change with -1 vote
+      PushOneCommit.Result r = createChange();
+      ReviewInput reviewInput = new ReviewInput().label(label.getName(), (short) -1);
+      revision(r).review(reviewInput);
+      ApprovalValues attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(0);
+      assertThat(attr.value).isEqualTo(-1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+    }
   }
 
   @Test
@@ -129,17 +123,19 @@
     PushOneCommit.Result r = createChange();
     ReviewInput reviewInput = new ReviewInput().message(label.getName());
     revision(r).review(reviewInput);
-
-    // push a new revision with +1 vote
-    ChangeInfo c = info(r.getChangeId());
-    r = amendChange(c.changeId);
-    reviewInput = new ReviewInput().label(label.getName(), (short) 1);
-    revision(r).review(reviewInput);
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 2: %s+1", label.getName()));
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      // push a new revision with +1 vote
+      ChangeInfo c = info(r.getChangeId());
+      r = amendChange(c.changeId);
+      reviewInput = new ReviewInput().label(label.getName(), (short) 1);
+      revision(r).review(reviewInput);
+      ApprovalValues attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(0);
+      assertThat(attr.value).isEqualTo(1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 2: %s+1", label.getName()));
+    }
   }
 
   @Test
@@ -149,114 +145,120 @@
     // push a change
     PushOneCommit.Result r = createChange();
 
-    // review with message only, do not apply votes
-    ReviewInput reviewInput = new ReviewInput().message(label.getName());
-    revision(r).review(reviewInput);
-    // reply message only so vote is shown as 0
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isNull();
-    assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      // review with message only, do not apply votes
+      ReviewInput reviewInput = new ReviewInput().message(label.getName());
+      revision(r).review(reviewInput);
+      // reply message only so vote is shown as 0
+      ApprovalValues attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isNull();
+      assertThat(attr.value).isEqualTo(0);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
 
-    // transition from un-voted to -1 vote
-    reviewInput = new ReviewInput().label(label.getName(), -1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+      // transition from un-voted to -1 vote
+      reviewInput = new ReviewInput().label(label.getName(), -1);
+      revision(r).review(reviewInput);
+      attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(0);
+      assertThat(attr.value).isEqualTo(-1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
 
-    // transition vote from -1 to 0
-    reviewInput = new ReviewInput().label(label.getName(), 0);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(-1);
-    assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: -%s", label.getName()));
+      // transition vote from -1 to 0
+      reviewInput = new ReviewInput().label(label.getName(), 0);
+      revision(r).review(reviewInput);
+      attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(-1);
+      assertThat(attr.value).isEqualTo(0);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: -%s", label.getName()));
 
-    // transition vote from 0 to 1
-    reviewInput = new ReviewInput().label(label.getName(), 1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s+1", label.getName()));
+      // transition vote from 0 to 1
+      reviewInput = new ReviewInput().label(label.getName(), 1);
+      revision(r).review(reviewInput);
+      attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(0);
+      assertThat(attr.value).isEqualTo(1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s+1", label.getName()));
 
-    // transition vote from 1 to -1
-    reviewInput = new ReviewInput().label(label.getName(), -1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(1);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+      // transition vote from 1 to -1
+      reviewInput = new ReviewInput().label(label.getName(), -1);
+      revision(r).review(reviewInput);
+      attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isEqualTo(1);
+      assertThat(attr.value).isEqualTo(-1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
 
-    // review with message only, do not apply votes
-    reviewInput = new ReviewInput().message(label.getName());
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isNull(); // no vote change so not included
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+      // review with message only, do not apply votes
+      reviewInput = new ReviewInput().message(label.getName());
+      revision(r).review(reviewInput);
+      attr = getApprovalValues(label, listener);
+      assertThat(attr.oldValue).isNull(); // no vote change so not included
+      assertThat(attr.value).isEqualTo(-1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+    }
   }
 
   @Test
   public void reviewChange_MultipleVotes() throws Exception {
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
-    reviewInput.message = label.getName();
-    revision(r).review(reviewInput);
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      saveLabelConfig();
+      PushOneCommit.Result r = createChange();
+      ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
+      reviewInput.message = label.getName();
+      revision(r).review(reviewInput);
 
-    ChangeInfo c = get(r.getChangeId(), DETAILED_LABELS);
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    ApprovalValues labelAttr = getApprovalValues(label);
-    assertThat(labelAttr.oldValue).isEqualTo(0);
-    assertThat(labelAttr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1\n\n%s", label.getName(), label.getName()));
+      ChangeInfo c = get(r.getChangeId(), DETAILED_LABELS);
+      LabelInfo q = c.labels.get(label.getName());
+      assertThat(q.all).hasSize(1);
+      ApprovalValues labelAttr = getApprovalValues(label, listener);
+      assertThat(labelAttr.oldValue).isEqualTo(0);
+      assertThat(labelAttr.value).isEqualTo(-1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s-1\n\n%s", label.getName(), label.getName()));
 
-    // there should be 3 approval labels (label, pLabel, and CRVV)
-    assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
+      // there should be 3 approval labels (label, pLabel, and CRVV)
+      assertThat(listener.getLastCommentAddedEvent().getApprovals()).hasSize(3);
 
-    // check the approvals that were not voted on
-    ApprovalValues pLabelAttr = getApprovalValues(pLabel);
-    assertThat(pLabelAttr.oldValue).isNull();
-    assertThat(pLabelAttr.value).isEqualTo(0);
+      // check the approvals that were not voted on
+      ApprovalValues pLabelAttr = getApprovalValues(pLabel, listener);
+      assertThat(pLabelAttr.oldValue).isNull();
+      assertThat(pLabelAttr.value).isEqualTo(0);
 
-    LabelType crLabel = LabelType.withDefaultValues("Code-Review");
-    ApprovalValues crlAttr = getApprovalValues(crLabel);
-    assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo(0);
+      LabelType crLabel = LabelType.withDefaultValues("Code-Review");
+      ApprovalValues crlAttr = getApprovalValues(crLabel, listener);
+      assertThat(crlAttr.oldValue).isNull();
+      assertThat(crlAttr.value).isEqualTo(0);
 
-    // update pLabel approval
-    reviewInput = new ReviewInput().label(pLabel.getName(), 1);
-    reviewInput.message = pLabel.getName();
-    revision(r).review(reviewInput);
+      // update pLabel approval
+      reviewInput = new ReviewInput().label(pLabel.getName(), 1);
+      reviewInput.message = pLabel.getName();
+      revision(r).review(reviewInput);
 
-    c = get(r.getChangeId(), DETAILED_LABELS);
-    q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    pLabelAttr = getApprovalValues(pLabel);
-    assertThat(pLabelAttr.oldValue).isEqualTo(0);
-    assertThat(pLabelAttr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s+1\n\n%s", pLabel.getName(), pLabel.getName()));
+      c = get(r.getChangeId(), DETAILED_LABELS);
+      q = c.labels.get(label.getName());
+      assertThat(q.all).hasSize(1);
+      pLabelAttr = getApprovalValues(pLabel, listener);
+      assertThat(pLabelAttr.oldValue).isEqualTo(0);
+      assertThat(pLabelAttr.value).isEqualTo(1);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1: %s+1\n\n%s", pLabel.getName(), pLabel.getName()));
 
-    // check the approvals that were not voted on
-    labelAttr = getApprovalValues(label);
-    assertThat(labelAttr.oldValue).isNull();
-    assertThat(labelAttr.value).isEqualTo(-1);
+      // check the approvals that were not voted on
+      labelAttr = getApprovalValues(label, listener);
+      assertThat(labelAttr.oldValue).isNull();
+      assertThat(labelAttr.value).isEqualTo(-1);
 
-    crlAttr = getApprovalValues(crLabel);
-    assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo(0);
+      crlAttr = getApprovalValues(crLabel, listener);
+      assertThat(crlAttr.oldValue).isNull();
+      assertThat(crlAttr.value).isEqualTo(0);
+    }
   }
 
   private static class ApprovalValues {
diff --git a/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java b/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java
new file mode 100644
index 0000000..8744cfad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.event;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class EventPayloadIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void defaultOptions() throws Exception {
+    RevisionCreatedListener listener =
+        new RevisionCreatedListener() {
+          @Override
+          public void onRevisionCreated(Event event) {
+            assertThat(event.getChange().submittable).isNotNull();
+            assertThat(event.getRevision().files).isNotEmpty();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      createChange();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "event.payload.listChangeOptions", value = "SKIP_MERGEABLE")
+  public void configuredOptions() throws Exception {
+    RevisionCreatedListener listener =
+        new RevisionCreatedListener() {
+          @Override
+          public void onRevisionCreated(Event event) {
+            assertThat(event.getChange().submittable).isNull();
+            assertThat(event.getChange().mergeable).isNull();
+            assertThat(event.getRevision().files).isNull();
+            assertThat(event.getChange().subject).isNotEmpty();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      createChange();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD b/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD
new file mode 100644
index 0000000..760e7f4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD
@@ -0,0 +1,8 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "receive",
+    labels = ["server"],
+    deps = [],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
new file mode 100644
index 0000000..6677583
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.git.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+
+/**
+ * Tests for comment validation when publishing drafts via the {@code --publish-comments} option.
+ */
+public class ReceiveCommitsCommentValidationIT extends AbstractDaemonTest {
+  @Inject private CommentValidator mockCommentValidator;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> capture;
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        CommentValidator mockCommentValidator = mock(CommentValidator.class);
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @Before
+  public void resetMock() {
+    initMocks(this);
+    clearInvocations(mockCommentValidator);
+  }
+
+  @Test
+  public void validateComments_commentOK() throws Exception {
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of());
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    amendResult.assertOkStatus();
+    amendResult.assertNotMessage("Comment validation failure:");
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateComments_commentRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    amendResult.assertOkStatus();
+    amendResult.assertMessage("Comment validation failure:");
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateComments_inlineVsFileComments_allOK() throws Exception {
+    when(mockCommentValidator.validateComments(capture.capture())).thenReturn(ImmutableList.of());
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput draftFile = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftFile);
+    DraftInput draftInline =
+        testCommentHelper.newDraft(
+            result.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(2);
+    assertThat(capture.getAllValues()).hasSize(1);
+    assertThat(capture.getValue())
+        .containsExactly(
+            CommentForValidation.create(
+                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+            CommentForValidation.create(
+                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/BUILD b/javatests/com/google/gerrit/acceptance/server/httpd/BUILD
new file mode 100644
index 0000000..d1a64c0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_httpd",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java b/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java
new file mode 100644
index 0000000..1dea800
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.httpd;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.URIish;
+import org.junit.Before;
+import org.junit.Test;
+
+public class HttpLogoutServletIT extends StandaloneSiteTest {
+  private static final String LOCALHOST = InetAddress.getLoopbackAddress().getHostName();
+
+  @ConfigSuite.Config
+  public static Config secondConfig() throws IOException {
+    Config cfg = new Config();
+    cfg.setString("auth", null, "logouturl", "/test-logout");
+    cfg.setString("gerrit", null, "canonicalWebUrl", "https://" + LOCALHOST + ":8443/");
+    cfg.setString("httpd", null, "listenUrl", "proxy-https://" + LOCALHOST + ":" + getFreePort());
+    return cfg;
+  }
+
+  @Inject @GerritServerConfig private Config gerritConfig;
+
+  private HttpClient httpClient;
+
+  @Before
+  public void setUp() {
+    httpClient = HttpClientBuilder.create().disableRedirectHandling().build();
+  }
+
+  @Test
+  public void shouldHonourCanonicalWebUrlProxyWhenRedirectAfterLogout() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      URIish listenUrl = new URIish(gerritConfig.getString("httpd", null, "listenUrl"));
+      URIish canonicalWebUrl =
+          new URIish(gerritConfig.getString("gerrit", null, "canonicalWebUrl"));
+
+      String logoutPath =
+          Optional.ofNullable(baseConfig.getString("auth", null, "logouturl")).orElse("/");
+
+      HttpGet getLogout = new HttpGet("/logout");
+      getLogout.addHeader("X-Forwarded-Host", canonicalWebUrl.getHost());
+      getLogout.addHeader("X-Forwarded-Port", "" + canonicalWebUrl.getPort());
+      getLogout.addHeader("X-Forwarded-Proto", canonicalWebUrl.getScheme());
+
+      HttpResponse logoutResponse =
+          httpClient.execute(new HttpHost(listenUrl.getHost(), listenUrl.getPort()), getLogout);
+
+      assertThat(logoutResponse.getStatusLine().getStatusCode())
+          .isEqualTo(HttpStatus.SC_MOVED_TEMPORARILY);
+      assertThat(getLocationHeaderURIish(logoutResponse))
+          .containsExactly(canonicalWebUrl.setPath(logoutPath));
+    }
+  }
+
+  private List<URIish> getLocationHeaderURIish(HttpResponse logoutResponse) {
+    return Arrays.stream(logoutResponse.getHeaders("Location"))
+        .map(h -> h.getValue())
+        .map(HttpLogoutServletIT::unsafeNewURIish)
+        .filter(u -> u.isPresent())
+        .map(u -> u.get())
+        .collect(Collectors.toList());
+  }
+
+  private static Optional<URIish> unsafeNewURIish(String uri) {
+    try {
+      return Optional.of(new URIish(uri));
+    } catch (URISyntaxException e) {
+      return Optional.empty();
+    }
+  }
+
+  private static int getFreePort() throws IOException {
+    try (ServerSocket s = new ServerSocket(0)) {
+      return s.getLocalPort();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/BUILD b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
index 5d7e65e..f90924b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
@@ -7,18 +7,18 @@
     "//java/com/google/gerrit/mail",
 ]
 
-acceptance_tests(
-    srcs = glob(
-        ["*IT.java"],
-        exclude = ["AbstractMailIT.java"],
-    ),
-    group = "server_mail",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = [
         "no_windows",
         "server",
     ],
     deps = DEPS + [":util"],
-)
+) for f in glob(
+    ["*IT.java"],
+    exclude = ["AbstractMailIT.java"],
+)]
 
 java_library(
     name = "util",
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 25e0708..2dc1e24 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.acceptance.server.mail;
 
+import static com.google.common.truth.Truth.assertWithMessage;
+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.api.changes.NotifyHandling.ALL;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
@@ -31,9 +34,11 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -50,9 +55,6 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
@@ -61,6 +63,7 @@
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   /*
@@ -80,14 +83,14 @@
 
   @Before
   public void grantPermissions() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.ABANDON, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
   }
 
   /*
@@ -599,7 +602,7 @@
       if (notify != null) {
         in.notify = notify;
       }
-      gApi.changes().id(changeId).revision("current").review(in);
+      gApi.changes().id(changeId).current().review(in);
     };
   }
 
@@ -858,7 +861,7 @@
   public void noCommentAndSetWorkInProgress() throws Exception {
     StagedChange sc = stageReviewableChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender).didNotSend();
   }
 
@@ -866,7 +869,7 @@
   public void commentAndSetWorkInProgress() throws Exception {
     StagedChange sc = stageReviewableChange();
     ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer)
@@ -881,7 +884,7 @@
   public void commentOnWipChangeAndStartReview() throws Exception {
     StagedChange sc = stageWipChange();
     ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer)
@@ -896,7 +899,7 @@
   public void addReviewerOnWipChangeAndStartReview() throws Exception {
     StagedChange sc = stageWipChange();
     ReviewInput in = ReviewInput.noScore().reviewer(other.email()).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer, other)
@@ -920,7 +923,7 @@
     StagedChange sc = stageWipChange();
     ReviewInput in =
         ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
     int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
@@ -950,7 +953,7 @@
     ReviewInput in = ReviewInput.recommend();
     in.notify = notify;
     in.tag = tag;
-    gApi.changes().id(changeId).revision("current").review(in);
+    gApi.changes().id(changeId).current().review(in);
   }
 
   /*
@@ -1253,7 +1256,7 @@
 
   private void recommend(StagedChange sc, TestAccount by) throws Exception {
     requestScopeOperations.setApiUser(by.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.recommend());
   }
 
   private interface Stager {
@@ -1267,7 +1270,7 @@
             .reviewer(extraReviewer.email())
             .reviewer(extraCcer.email(), ReviewerState.CC, false);
     requestScopeOperations.setApiUser(extraReviewer.id());
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     sender.clear();
     return sc;
   }
@@ -1516,15 +1519,16 @@
       }
 
       merge(sc.changeId, sc.owner);
-      assertThat(sender)
-          .named(name)
+      assertWithMessage(name)
+          .about(fakeEmailSenders())
+          .that(sender)
           .sent("merged", sc)
           .cc(sc.reviewer, sc.ccer)
           .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
           .bcc(sc.starrer)
           .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
           .noOneElse();
-      assertThat(sender).named(name).didNotSend();
+      assertWithMessage(name).about(fakeEmailSenders()).that(sender).didNotSend();
     }
   }
 
@@ -1626,7 +1630,7 @@
       throws Exception {
     setEmailStrategy(by, emailStrategy);
     requestScopeOperations.setApiUser(by.id());
-    gApi.changes().id(changeId).revision("current").submit();
+    gApi.changes().id(changeId).current().submit();
   }
 
   private void merge(String changeId, TestAccount by, NotifyHandling notify) throws Exception {
@@ -1640,13 +1644,13 @@
     requestScopeOperations.setApiUser(by.id());
     SubmitInput in = new SubmitInput();
     in.notify = notify;
-    gApi.changes().id(changeId).revision("current").submit(in);
+    gApi.changes().id(changeId).current().submit(in);
   }
 
   private StagedChange stageChangeReadyForMerge() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(sc.reviewer.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
     sender.clear();
     return sc;
   }
@@ -2039,7 +2043,7 @@
       StagedChange sc, TestAccount by, @Nullable NotifyHandling notify, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    CommitInfo commit = gApi.changes().id(sc.changeId).revision("current").commit(false);
+    CommitInfo commit = gApi.changes().id(sc.changeId).current().commit(false);
     CommitMessageInput in = new CommitMessageInput();
     in.message = "update\n" + commit.message;
     in.notify = notify;
@@ -2254,8 +2258,8 @@
   private StagedChange stageChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(sc.changeId).revision("current").submit();
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).current().submit();
     sender.clear();
     return sc;
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 1386aec..0826c166 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -27,7 +28,6 @@
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
 import java.time.ZoneId;
@@ -37,28 +37,14 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 /** Tests the presence of required metadata in email headers, text and html. */
+@UseClockStep
+@UseTimezone(timezone = "US/Eastern")
 public class MailMetadataIT extends AbstractDaemonTest {
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
   @Test
   public void metadataOnNewChange() throws Exception {
     PushOneCommit.Result newChange = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index f917fd8..5531709 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -15,26 +15,68 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
+import com.google.inject.Module;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
 import java.util.List;
+import org.junit.Before;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class MailProcessorIT extends AbstractMailIT {
   @Inject private MailProcessor mailProcessor;
   @Inject private AccountOperations accountOperations;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final CommentValidator mockCommentValidator = mock(CommentValidator.class);
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @BeforeClass
+  public static void setUpMock() {
+    // Let the mock comment validator accept all comments during test setup.
+    when(mockCommentValidator.validateComments(any())).thenReturn(ImmutableList.of());
+  }
+
+  @Before
+  public void setUp() {
+    clearInvocations(mockCommentValidator);
+  }
 
   @Test
   public void parseAndPersistChangeMessage() throws Exception {
@@ -223,7 +265,87 @@
     assertThat(message.headers()).containsKey("Subject");
   }
 
+  @Test
+  public void validateChangeMessage_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    setupFailValidation(CommentForValidation.CommentType.CHANGE_MESSAGE);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
+  @Test
+  public void validateInlineComment_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    setupFailValidation(CommentForValidation.CommentType.INLINE_COMMENT);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
+  @Test
+  public void validateFileComment_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    setupFailValidation(CommentForValidation.CommentType.FILE_COMMENT);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
   private String getChangeUrl(ChangeInfo changeInfo) {
     return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
   }
+
+  private void setupFailValidation(CommentForValidation.CommentType type) {
+    CommentForValidation commentForValidation = CommentForValidation.create(type, COMMENT_TEXT);
+
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(CommentForValidation.create(type, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
index bdb3f3b..ee3bcb0 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -7,9 +7,6 @@
         "notedb",
         "server",
     ],
-    # TODO(dborowitz): Fix leaks in local disk tests so we can reduce heap size.
-    # http://crbug.com/gerrit/8567
-    vm_args = ["-Xmx1024m"],
     deps = [
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 748c4ea..c502c79 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -15,17 +15,19 @@
 package com.google.gerrit.acceptance.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateListener;
@@ -126,12 +128,9 @@
               throw new ResourceConflictException(msg);
             }
           });
-      try {
-        bu.execute();
-        fail("expected ResourceConflictException");
-      } catch (ResourceConflictException e) {
-        assertThat(e).hasMessageThat().isEqualTo(msg);
-      }
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown).hasMessageThat().isEqualTo(msg);
     }
 
     // If updateChange hadn't failed, backup would have been updated to master2.
@@ -188,18 +187,13 @@
 
   @Test
   public void missingChange() throws Exception {
-    Change.Id changeId = new Change.Id(1234567);
+    Change.Id changeId = Change.id(1234567);
     assertNoSuchChangeException(() -> notesFactory.create(project, changeId));
     assertNoSuchChangeException(() -> notesFactory.createChecked(project, changeId));
   }
 
   private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
-    try {
-      callable.call();
-      fail("expected NoSuchChangeException");
-    } catch (NoSuchChangeException e) {
-      // Expected.
-    }
+    assertThrows(NoSuchChangeException.class, () -> callable.call());
   }
 
   private class ConcurrentWritingListener implements BatchUpdateListener {
@@ -306,8 +300,8 @@
     if (repo instanceof InMemoryRepository) {
       ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
     } else {
-      assertThat(repo.getRefDatabase().performsAtomicTransactions())
-          .named("performsAtomicTransactions on %s", repo)
+      assertWithMessage("performsAtomicTransactions on %s", repo)
+          .that(repo.getRefDatabase().performsAtomicTransactions())
           .isTrue();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
index 2919e5f..12cae84 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
@@ -19,9 +19,9 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -122,7 +122,7 @@
 
   @Test
   public void refPermissions_sameResourceAndUserEquals() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
 
@@ -132,7 +132,7 @@
 
   @Test
   public void refPermissions_sameResourceAndDifferentUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(admin()).ref(branch).testCond(RefPermission.READ);
 
@@ -142,8 +142,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(project, "branch2");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(project, "branch2");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
@@ -153,8 +153,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual2() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(projectOperations.newProject().create(), "branch");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(projectOperations.newProject().create(), "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6cbe40e..3b7d826 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
@@ -24,68 +26,50 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.Arrays;
-import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
 
-  @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
-  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
-
-  private RegistrationHandle eventListenerRegistration;
-  private CommentAddedListener.Event lastCommentAddedEvent;
+  private final LabelType P = label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-      u.save();
-    }
-
-    eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
-  }
-
-  @After
-  public void cleanup() {
-    eventListenerRegistration.remove();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(P.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
   }
 
   @Test
@@ -172,28 +156,41 @@
     assertThat(q.blocking).isTrue();
   }
 
+  private static class TestListener implements CommentAddedListener {
+    public CommentAddedListener.Event lastCommentAddedEvent;
+
+    @Override
+    public void onCommentAdded(Event event) {
+      lastCommentAddedEvent = event;
+    }
+  }
+
   @Test
   public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
-    P.setFunction(ANY_WITH_BLOCK);
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    TestListener testListener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
+      P.setFunction(ANY_WITH_BLOCK);
+      saveLabelConfig();
+      PushOneCommit.Result r = createChange();
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = user.email();
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    ReviewInput input = new ReviewInput().label(P.getName(), 0);
-    input.message = "foo";
+      ReviewInput input = new ReviewInput().label(P.getName(), 0);
+      input.message = "foo";
 
-    revision(r).review(input);
-    ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(P.getName());
-    assertThat(q.all).hasSize(2);
-    assertThat(q.approved).isNull();
-    assertThat(q.recommended).isNull();
-    assertThat(q.disliked).isNull();
-    assertThat(q.rejected).isNull();
-    assertThat(q.blocking).isNull();
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo("Patch Set 1:\n\n" + input.message);
+      revision(r).review(input);
+      ChangeInfo c = getWithLabels(r);
+      LabelInfo q = c.labels.get(P.getName());
+      assertThat(q.all).hasSize(1);
+      assertThat(q.approved).isNull();
+      assertThat(q.recommended).isNull();
+      assertThat(q.disliked).isNull();
+      assertThat(q.rejected).isNull();
+      assertThat(q.blocking).isNull();
+      assertThat(testListener.lastCommentAddedEvent.getComment())
+          .isEqualTo("Patch Set 1:\n\n" + input.message);
+    }
   }
 
   @Test
@@ -265,15 +262,17 @@
     assertPermitted(info, P.getName(), 0, 1);
     assertPermitted(info, label.getName());
 
-    ReviewInput in = new ReviewInput();
-    in.label(P.getName(), P.getMax().getValue());
-    revision(r).review(in);
+    ReviewInput postSubmitReview1 = new ReviewInput();
+    postSubmitReview1.label(P.getName(), P.getMax().getValue());
+    revision(r).review(postSubmitReview1);
 
-    in = new ReviewInput();
-    in.label(label.getName(), label.getMax().getValue());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Voting on labels disallowed after submit: " + label.getName());
-    revision(r).review(in);
+    ReviewInput postSubmitReview2 = new ReviewInput();
+    postSubmitReview2.label(label.getName(), label.getMax().getValue());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(postSubmitReview2));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Voting on labels disallowed after submit: " + label.getName());
   }
 
   @Test
@@ -289,11 +288,11 @@
         value(-1, "I would prefer this is not merged as is"),
         value(-2, "This shall not be merged"));
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -311,11 +310,12 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     // Update admin's permitted range for 'Test-Label' to be -1...+1.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(testLabel), registered, "refs/heads/*");
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(testLabel).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     // Verify admin doesn't have +2 permission any more.
     assertPermitted(gApi.changes().id(changeId).get(), testLabel, -1, 0, 1);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 4ed16ee..29574c4 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 
 import com.google.common.collect.ImmutableSet;
@@ -25,12 +26,12 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.testing.FakeEmailSender.Message;
@@ -218,7 +219,7 @@
     // push a change to watched project -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
@@ -229,7 +230,7 @@
     // notification
     String notWatchedProject = projectOperations.newProject().create().get();
     TestRepository<InMemoryRepository> notWatchedRepo =
-        cloneProject(new Project.NameKey(notWatchedProject), admin);
+        cloneProject(Project.nameKey(notWatchedProject), admin);
     r =
         pushFactory
             .create(admin.newIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
@@ -261,7 +262,7 @@
     // user
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
@@ -305,15 +306,15 @@
     requestScopeOperations.setApiUser(user.id());
 
     // watch keyword in project as user
-    watch(watchedProject, "multimaster");
+    watch(watchedProject, "multiprimary");
 
     // push a change with keyword -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(admin.newIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
+            .create(admin.newIdent(), watchedRepo, "Document multiprimary setup", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -322,7 +323,7 @@
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
-    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Change subject: Document multiprimary setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
@@ -347,8 +348,7 @@
 
     // push a change to any project -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory.create(admin.newIdent(), anyRepo, "TRIGGER", "a", "a1").to("refs/for/master");
     r.assertOkStatus();
@@ -374,8 +374,7 @@
     // push a change to watched file in any project -> should trigger email
     // notification for user
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
@@ -419,16 +418,15 @@
     requestScopeOperations.setApiUser(user.id());
 
     // watch keyword in project as user
-    watch(allProjects.get(), "multimaster");
+    watch(allProjects.get(), "multiprimary");
 
     // push a change with keyword to any project -> should trigger email
     // notification
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(admin.newIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
+            .create(admin.newIdent(), anyRepo, "Document multiprimary setup", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -437,7 +435,7 @@
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
-    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Change subject: Document multiprimary setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
@@ -463,7 +461,7 @@
     // push a change to watched project
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
@@ -496,7 +494,7 @@
     // push a private change to watched project -> should not trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "private change", "a", "a1")
@@ -514,12 +512,14 @@
     // create group that can view all private changes
     GroupInfo groupThatCanViewPrivateChanges =
         gApi.groups().create("groupThatCanViewPrivateChanges").get();
-    grant(
-        new Project.NameKey(watchedProject),
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+    projectOperations
+        .project(Project.nameKey(watchedProject))
+        .forUpdate()
+        .add(
+            allow(Permission.VIEW_PRIVATE_CHANGES)
+                .ref("refs/*")
+                .group(AccountGroup.uuid(groupThatCanViewPrivateChanges.id)))
+        .update();
 
     // watch project as user that can't view private changes
     requestScopeOperations.setApiUser(user.id());
@@ -536,7 +536,7 @@
     // userThatCanViewPrivateChanges, but not for user
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index da3a257..11d39b4 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -15,21 +15,24 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.io.File;
 import java.util.List;
@@ -39,6 +42,7 @@
 
 @UseLocalDisk
 public class ReflogIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -55,7 +59,7 @@
 
       gApi.changes().id(id.get()).topic("foo");
       ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertWithMessage("last RefLogEntry").that(last).isNotNull();
       assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
     }
   }
@@ -85,8 +89,8 @@
   @Test
   public void regularUserIsNotAllowedToGetReflog() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).branch("master").reflog();
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(project.get()).branch("master").reflog());
   }
 
   @Test
@@ -94,11 +98,11 @@
     GroupApi groupApi = gApi.groups().create(name("get-reflog"));
     groupApi.addMembers("user");
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(AccountGroup.uuid(groupApi.get().id)))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.projects().name(project.get()).branch("master").reflog();
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
index adc7807..b3647e7 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.resetToStrict;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.quota.QuotaBackend;
 import com.google.gerrit.server.quota.QuotaEnforcer;
@@ -34,13 +35,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.Collections;
-import org.easymock.EasyMock;
 import org.junit.Before;
 import org.junit.Test;
 
 public class DefaultQuotaBackendIT extends AbstractDaemonTest {
 
-  private static final QuotaEnforcer quotaEnforcer = EasyMock.createStrictMock(QuotaEnforcer.class);
+  private static final QuotaEnforcer quotaEnforcer = mock(QuotaEnforcer.class);
 
   private IdentifiedUser identifiedAdmin;
   @Inject private QuotaBackend quotaBackend;
@@ -60,14 +60,13 @@
   @Before
   public void setUp() {
     identifiedAdmin = identifiedUserFactory.create(admin.id());
-    resetToStrict(quotaEnforcer);
+    clearInvocations(quotaEnforcer);
   }
 
   @Test
   public void requestTokenForUser() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
     assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
   }
@@ -76,8 +75,7 @@
   public void requestTokenForUserAndAccount() {
     QuotaRequestContext ctx =
         QuotaRequestContext.builder().user(identifiedAdmin).account(user.id()).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
     assertThat(quotaBackend.user(identifiedAdmin).account(user.id()).requestToken("testGroup"))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
   }
@@ -86,8 +84,7 @@
   public void requestTokenForUserAndProject() {
     QuotaRequestContext ctx =
         QuotaRequestContext.builder().user(identifiedAdmin).project(project).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
     assertThat(quotaBackend.user(identifiedAdmin).project(project).requestToken("testGroup"))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
   }
@@ -101,8 +98,7 @@
             .change(changeId)
             .project(project)
             .build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
     assertThat(
             quotaBackend.user(identifiedAdmin).change(changeId, project).requestToken("testGroup"))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
@@ -111,8 +107,7 @@
   @Test
   public void requestTokens() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 123)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 123)).thenReturn(QuotaResponse.ok());
     assertThat(quotaBackend.user(identifiedAdmin).requestTokens("testGroup", 123))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
   }
@@ -120,8 +115,7 @@
   @Test
   public void dryRun() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.dryRun("testGroup", ctx, 123)).andReturn(QuotaResponse.ok());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.dryRun("testGroup", ctx, 123)).thenReturn(QuotaResponse.ok());
     assertThat(quotaBackend.user(identifiedAdmin).dryRun("testGroup", 123))
         .isEqualTo(singletonAggregation(QuotaResponse.ok()));
   }
@@ -131,8 +125,7 @@
     QuotaRequestContext ctx =
         QuotaRequestContext.builder().user(identifiedAdmin).account(user.id()).build();
     QuotaResponse r = QuotaResponse.ok(10L);
-    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenReturn(r);
     assertThat(quotaBackend.user(identifiedAdmin).account(user.id()).availableTokens("testGroup"))
         .isEqualTo(singletonAggregation(r));
   }
@@ -142,8 +135,7 @@
     QuotaRequestContext ctx =
         QuotaRequestContext.builder().user(identifiedAdmin).project(project).build();
     QuotaResponse r = QuotaResponse.ok(10L);
-    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenReturn(r);
     assertThat(quotaBackend.user(identifiedAdmin).project(project).availableTokens("testGroup"))
         .isEqualTo(singletonAggregation(r));
   }
@@ -158,8 +150,7 @@
             .project(project)
             .build();
     QuotaResponse r = QuotaResponse.ok(10L);
-    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenReturn(r);
     assertThat(
             quotaBackend
                 .user(identifiedAdmin)
@@ -172,8 +163,7 @@
   public void availableTokens() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
     QuotaResponse r = QuotaResponse.ok(10L);
-    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenReturn(r);
     assertThat(quotaBackend.user(identifiedAdmin).availableTokens("testGroup"))
         .isEqualTo(singletonAggregation(r));
   }
@@ -181,56 +171,51 @@
   @Test
   public void requestTokenError() throws Exception {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1))
-        .andReturn(QuotaResponse.error("failed"));
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1))
+        .thenReturn(QuotaResponse.error("failed"));
 
     QuotaResponse.Aggregated result = quotaBackend.user(identifiedAdmin).requestToken("testGroup");
     assertThat(result).isEqualTo(singletonAggregation(QuotaResponse.error("failed")));
-    exception.expect(QuotaException.class);
-    exception.expectMessage("failed");
-    result.throwOnError();
+    QuotaException thrown = assertThrows(QuotaException.class, () -> result.throwOnError());
+    assertThat(thrown).hasMessageThat().contains("failed");
   }
 
   @Test
   public void availableTokensError() throws Exception {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.availableTokens("testGroup", ctx))
-        .andReturn(QuotaResponse.error("failed"));
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.error("failed"));
     QuotaResponse.Aggregated result =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup");
     assertThat(result).isEqualTo(singletonAggregation(QuotaResponse.error("failed")));
-    exception.expect(QuotaException.class);
-    exception.expectMessage("failed");
-    result.throwOnError();
+    QuotaException thrown = assertThrows(QuotaException.class, () -> result.throwOnError());
+    assertThat(thrown).hasMessageThat().contains("failed");
   }
 
   @Test
   public void requestTokenPluginThrowsAndRethrows() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andThrow(new NullPointerException());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.requestTokens("testGroup", ctx, 1)).thenThrow(new NullPointerException());
 
-    exception.expect(NullPointerException.class);
-    quotaBackend.user(identifiedAdmin).requestToken("testGroup");
+    assertThrows(
+        NullPointerException.class,
+        () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
   }
 
   @Test
   public void availableTokensPluginThrowsAndRethrows() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andThrow(new NullPointerException());
-    replay(quotaEnforcer);
+    when(quotaEnforcer.availableTokens("testGroup", ctx)).thenThrow(new NullPointerException());
 
-    exception.expect(NullPointerException.class);
-    quotaBackend.user(identifiedAdmin).availableTokens("testGroup");
+    assertThrows(
+        NullPointerException.class,
+        () -> quotaBackend.user(identifiedAdmin).availableTokens("testGroup"));
   }
 
   private Change.Id retrieveChangeId() throws Exception {
     // use REST API so that repository size quota doesn't have to be stubbed
     ChangeInfo changeInfo =
         gApi.changes().create(new ChangeInput(project.get(), "master", "test")).get();
-    return new Change.Id(changeInfo._number);
+    return Change.id(changeInfo._number);
   }
 
   private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index f5f1161..c6b09cc 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.resetToStrict;
-import static org.easymock.EasyMock.verify;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -33,15 +34,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.OptionalLong;
-import org.easymock.EasyMock;
 import org.junit.Before;
 import org.junit.Test;
 
 public class MultipleQuotaPluginsIT extends AbstractDaemonTest {
-  private static final QuotaEnforcer quotaEnforcerA =
-      EasyMock.createStrictMock(QuotaEnforcer.class);
-  private static final QuotaEnforcer quotaEnforcerB =
-      EasyMock.createStrictMock(QuotaEnforcer.class);
+  private static final QuotaEnforcer quotaEnforcerA = mock(QuotaEnforcer.class);
+  private static final QuotaEnforcer quotaEnforcerB = mock(QuotaEnforcer.class);
 
   private IdentifiedUser identifiedAdmin;
   @Inject private QuotaBackend quotaBackend;
@@ -65,93 +63,86 @@
   @Before
   public void setUp() {
     identifiedAdmin = identifiedUserFactory.create(admin.id());
-    resetToStrict(quotaEnforcerA);
-    resetToStrict(quotaEnforcerB);
+    clearInvocations(quotaEnforcerA);
+    clearInvocations(quotaEnforcerB);
   }
 
   @Test
   public void refillsOnError() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1))
-        .andReturn(QuotaResponse.error("fail"));
-    quotaEnforcerA.refill("testGroup", ctx, 1);
-    expectLastCall();
-
-    replay(quotaEnforcerA);
-    replay(quotaEnforcerB);
+    when(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
+    when(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.error("fail"));
 
     assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
         .isEqualTo(
             QuotaResponse.Aggregated.create(
                 ImmutableList.of(QuotaResponse.ok(), QuotaResponse.error("fail"))));
+
+    verify(quotaEnforcerA).requestTokens("testGroup", ctx, 1);
+    verify(quotaEnforcerB).requestTokens("testGroup", ctx, 1);
+    verify(quotaEnforcerA).refill("testGroup", ctx, 1);
   }
 
   @Test
   public void refillsOnException() {
     NullPointerException exception = new NullPointerException();
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andThrow(exception);
-    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    quotaEnforcerB.refill("testGroup", ctx, 1);
-    expectLastCall();
+    when(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.ok());
+    when(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).thenThrow(exception);
 
-    replay(quotaEnforcerA);
-    replay(quotaEnforcerB);
+    NullPointerException thrown =
+        assertThrows(
+            NullPointerException.class,
+            () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
+    assertThat(thrown).isEqualTo(exception);
 
-    try {
-      quotaBackend.user(identifiedAdmin).requestToken("testGroup");
-      fail("expected a NullPointerException");
-    } catch (NullPointerException e) {
-      assertThat(exception).isEqualTo(e);
-    }
-
-    verify(quotaEnforcerA);
+    verify(quotaEnforcerA).requestTokens("testGroup", ctx, 1);
+    verify(quotaEnforcerB).requestTokens("testGroup", ctx, 1);
+    verify(quotaEnforcerA).refill("testGroup", ctx, 1);
   }
 
   @Test
   public void doesNotRefillNoOp() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1))
-        .andReturn(QuotaResponse.error("fail"));
-    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.noOp());
-
-    replay(quotaEnforcerA);
-    replay(quotaEnforcerB);
+    when(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.error("fail"));
+    when(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).thenReturn(QuotaResponse.noOp());
 
     assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
         .isEqualTo(
             QuotaResponse.Aggregated.create(
                 ImmutableList.of(QuotaResponse.error("fail"), QuotaResponse.noOp())));
+
+    verify(quotaEnforcerA).requestTokens("testGroup", ctx, 1);
+    verify(quotaEnforcerB).requestTokens("testGroup", ctx, 1);
   }
 
   @Test
   public void minimumAvailableTokens() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(20L));
-    expect(quotaEnforcerB.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(10L));
-
-    replay(quotaEnforcerA);
-    replay(quotaEnforcerB);
+    when(quotaEnforcerA.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.ok(20L));
+    when(quotaEnforcerB.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.ok(10L));
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(10L);
+
+    verify(quotaEnforcerA).availableTokens("testGroup", ctx);
+    verify(quotaEnforcerB).availableTokens("testGroup", ctx);
   }
 
   @Test
   public void ignoreNoOpForAvailableTokens() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.noOp());
-    expect(quotaEnforcerB.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(20L));
-
-    replay(quotaEnforcerA);
-    replay(quotaEnforcerB);
+    when(quotaEnforcerA.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.noOp());
+    when(quotaEnforcerB.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.ok(20L));
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(20L);
+
+    verify(quotaEnforcerA).availableTokens("testGroup", ctx);
+    verify(quotaEnforcerB).availableTokens("testGroup", ctx);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
index 0814230..801288a 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
 import static com.google.gerrit.server.quota.QuotaResponse.ok;
-import static org.easymock.EasyMock.anyLong;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.resetToStrict;
-import static org.easymock.EasyMock.verify;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseLocalDisk;
@@ -33,7 +34,6 @@
 import com.google.gerrit.server.quota.QuotaResponse;
 import com.google.inject.Module;
 import java.util.Collections;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.api.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.junit.Before;
@@ -42,9 +42,9 @@
 @UseLocalDisk
 public class RepositorySizeQuotaIT extends AbstractDaemonTest {
   private static final QuotaBackend.WithResource quotaBackendWithResource =
-      EasyMock.createStrictMock(QuotaBackend.WithResource.class);
+      mock(QuotaBackend.WithResource.class);
   private static final QuotaBackend.WithUser quotaBackendWithUser =
-      EasyMock.createStrictMock(QuotaBackend.WithUser.class);
+      mock(QuotaBackend.WithUser.class);
 
   @Override
   public Module createModule() {
@@ -70,64 +70,42 @@
 
   @Before
   public void setUp() {
-    resetToStrict(quotaBackendWithResource);
-    resetToStrict(quotaBackendWithUser);
+    clearInvocations(quotaBackendWithResource);
+    clearInvocations(quotaBackendWithUser);
   }
 
   @Test
   public void pushWithAvailableTokens() throws Exception {
-    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .andReturn(singletonAggregation(ok(276L)))
-        .times(2);
-    expect(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
-        .andReturn(singletonAggregation(ok()));
-    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
-    replay(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .thenReturn(singletonAggregation(ok(276L)));
+    when(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
+        .thenReturn(singletonAggregation(ok()));
+    when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
     pushCommit();
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    verify(quotaBackendWithResource, times(2)).availableTokens(REPOSITORY_SIZE_GROUP);
   }
 
   @Test
   public void pushWithNotSufficientTokens() throws Exception {
     long availableTokens = 1L;
-    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .andReturn(singletonAggregation(ok(availableTokens)))
-        .anyTimes();
-    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
-    replay(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
-    try {
-      pushCommit();
-      assert_().fail("expected TooLargeObjectInPackException");
-    } catch (TooLargeObjectInPackException e) {
-      String msg = e.getMessage();
-      assertThat(msg).contains("Object too large");
-      assertThat(msg)
-          .contains(String.format("Max object size limit is %d bytes.", availableTokens));
-    }
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .thenReturn(singletonAggregation(ok(availableTokens)));
+    when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
+    TooLargeObjectInPackException thrown =
+        assertThrows(TooLargeObjectInPackException.class, () -> pushCommit());
+    assertThat(thrown).hasMessageThat().contains("Object too large");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Max object size limit is %d bytes.", availableTokens));
   }
 
   @Test
   public void errorGettingAvailableTokens() throws Exception {
     String msg = "quota error";
-    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .andReturn(singletonAggregation(QuotaResponse.error(msg)))
-        .anyTimes();
-    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
-    replay(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
-    try {
-      pushCommit();
-      assert_().fail("expected TransportException");
-    } catch (TransportException e) {
-      // TransportException has not much info about the cause
-    }
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .thenReturn(singletonAggregation(QuotaResponse.error(msg)));
+    when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
+    assertThrows(TransportException.class, () -> pushCommit());
   }
 
   private void pushCommit() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java
index dc082fe..c0aab38 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java
@@ -14,30 +14,30 @@
 
 package com.google.gerrit.acceptance.server.quota;
 
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.reset;
-import static org.easymock.EasyMock.verify;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_TOO_MANY_REQUESTS;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.quota.QuotaBackend;
 import com.google.gerrit.server.quota.QuotaResponse;
 import com.google.inject.Module;
 import java.util.Collections;
-import org.easymock.EasyMock;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RestApiQuotaIT extends AbstractDaemonTest {
   private static final QuotaBackend.WithResource quotaBackendWithResource =
-      EasyMock.createStrictMock(QuotaBackend.WithResource.class);
+      mock(QuotaBackend.WithResource.class);
   private static final QuotaBackend.WithUser quotaBackendWithUser =
-      EasyMock.createStrictMock(QuotaBackend.WithUser.class);
+      mock(QuotaBackend.WithUser.class);
 
   @Override
   public Module createModule() {
@@ -63,79 +63,72 @@
 
   @Before
   public void setUp() {
-    reset(quotaBackendWithResource);
-    reset(quotaBackendWithUser);
+    clearInvocations(quotaBackendWithResource);
+    clearInvocations(quotaBackendWithUser);
   }
 
   @Test
   public void changeDetail() throws Exception {
     Change.Id changeId = retrieveChangeId();
-    expect(quotaBackendWithResource.requestToken("/restapi/changes/detail:GET"))
-        .andReturn(singletonAggregation(QuotaResponse.ok()));
-    replay(quotaBackendWithResource);
-    expect(quotaBackendWithUser.change(changeId, project)).andReturn(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithResource.requestToken("/restapi/changes/detail:GET"))
+        .thenReturn(singletonAggregation(QuotaResponse.ok()));
+    when(quotaBackendWithUser.change(changeId, project)).thenReturn(quotaBackendWithResource);
     adminRestSession.get("/changes/" + changeId + "/detail").assertOK();
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    verify(quotaBackendWithResource).requestToken("/restapi/changes/detail:GET");
+    verify(quotaBackendWithUser).change(changeId, project);
   }
 
   @Test
   public void revisionDetail() throws Exception {
     Change.Id changeId = retrieveChangeId();
-    expect(quotaBackendWithResource.requestToken("/restapi/changes/revisions/actions:GET"))
-        .andReturn(singletonAggregation(QuotaResponse.ok()));
-    replay(quotaBackendWithResource);
-    expect(quotaBackendWithUser.change(changeId, project)).andReturn(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithResource.requestToken("/restapi/changes/revisions/actions:GET"))
+        .thenReturn(singletonAggregation(QuotaResponse.ok()));
+    when(quotaBackendWithUser.change(changeId, project)).thenReturn(quotaBackendWithResource);
     adminRestSession.get("/changes/" + changeId + "/revisions/current/actions").assertOK();
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    verify(quotaBackendWithResource).requestToken("/restapi/changes/revisions/actions:GET");
+    verify(quotaBackendWithUser).change(changeId, project);
   }
 
   @Test
   public void createChangePost() throws Exception {
-    expect(quotaBackendWithUser.requestToken("/restapi/changes:POST"))
-        .andReturn(singletonAggregation(QuotaResponse.ok()));
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithUser.requestToken("/restapi/changes:POST"))
+        .thenReturn(singletonAggregation(QuotaResponse.ok()));
     ChangeInput changeInput = new ChangeInput(project.get(), "master", "test");
     adminRestSession.post("/changes/", changeInput).assertCreated();
-    verify(quotaBackendWithUser);
+    verify(quotaBackendWithUser).requestToken("/restapi/changes:POST");
   }
 
   @Test
   public void accountDetail() throws Exception {
-    expect(quotaBackendWithResource.requestToken("/restapi/accounts/detail:GET"))
-        .andReturn(singletonAggregation(QuotaResponse.ok()));
-    replay(quotaBackendWithResource);
-    expect(quotaBackendWithUser.account(admin.id())).andReturn(quotaBackendWithResource);
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithResource.requestToken("/restapi/accounts/detail:GET"))
+        .thenReturn(singletonAggregation(QuotaResponse.ok()));
+    when(quotaBackendWithUser.account(admin.id())).thenReturn(quotaBackendWithResource);
     adminRestSession.get("/accounts/self/detail").assertOK();
-    verify(quotaBackendWithUser);
-    verify(quotaBackendWithResource);
+    verify(quotaBackendWithResource).requestToken("/restapi/accounts/detail:GET");
+    verify(quotaBackendWithUser).account(admin.id());
   }
 
   @Test
   public void config() throws Exception {
-    expect(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
-        .andReturn(singletonAggregation(QuotaResponse.ok()));
-    replay(quotaBackendWithUser);
+    when(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
+        .thenReturn(singletonAggregation(QuotaResponse.ok()));
     adminRestSession.get("/config/server/version").assertOK();
+    verify(quotaBackendWithUser).requestToken("/restapi/config/version:GET");
   }
 
   @Test
   public void outOfQuotaReturnsError() throws Exception {
-    expect(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
-        .andReturn(singletonAggregation(QuotaResponse.error("no quota")));
-    replay(quotaBackendWithUser);
-    adminRestSession.get("/config/server/version").assertStatus(429);
+    when(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
+        .thenReturn(singletonAggregation(QuotaResponse.error("no quota")));
+    adminRestSession.get("/config/server/version").assertStatus(SC_TOO_MANY_REQUESTS);
+    verify(quotaBackendWithUser).requestToken("/restapi/config/version:GET");
   }
 
   private Change.Id retrieveChangeId() throws Exception {
     // use REST API so that repository size quota doesn't have to be stubbed
     ChangeInfo changeInfo =
         gApi.changes().create(new ChangeInput(project.get(), "master", "test")).get();
-    return new Change.Id(changeInfo._number);
+    return Change.id(changeInfo._number);
   }
 
   private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index 83782c9..1c820af 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.rules;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -22,11 +23,10 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.inject.Inject;
-import java.util.Collection;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
@@ -42,11 +42,10 @@
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
 
-    Collection<SubmitRecord> submitRecords =
-        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    Optional<SubmitRecord> submitRecord = rule.evaluate(r.getChange());
 
-    assertThat(submitRecords).hasSize(1);
-    SubmitRecord result = submitRecords.iterator().next();
+    assertThat(submitRecord).isPresent();
+    SubmitRecord result = submitRecord.get();
     assertThat(result.status).isEqualTo(SubmitRecord.Status.NOT_READY);
     assertThat(result.labels).isNotEmpty();
     assertThat(result.requirements)
@@ -69,9 +68,8 @@
     // Approve as admin
     approve(r.getChangeId());
 
-    Collection<SubmitRecord> submitRecords =
-        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
-    assertThat(submitRecords).isEmpty();
+    Optional<SubmitRecord> submitRecord = rule.evaluate(r.getChange());
+    assertThat(submitRecord).isEmpty();
   }
 
   @Test
@@ -81,9 +79,8 @@
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
 
-    Collection<SubmitRecord> submitRecords =
-        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
-    assertThat(submitRecords).isEmpty();
+    Optional<SubmitRecord> submitRecord = rule.evaluate(r.getChange());
+    assertThat(submitRecord).isEmpty();
   }
 
   private void enableRule(String labelName, boolean newState) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index fa13be4..92cc396 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRuleEvaluator;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
@@ -30,8 +30,8 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorIT extends AbstractDaemonTest {
@@ -45,9 +45,9 @@
     StructureTerm labels = new StructureTerm("label", verifiedLabel);
 
     List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
-    Collection<SubmitRecord> records = evaluator.resultsToSubmitRecord(null, terms);
+    SubmitRecord record = evaluator.resultsToSubmitRecord(null, terms);
 
-    assertThat(records).hasSize(1);
+    assertThat(record.status).isEqualTo(SubmitRecord.Status.OK);
   }
 
   /**
@@ -112,23 +112,16 @@
     terms.add(makeTerm("not_ready", makeLabels(label3)));
 
     // When
-    List<SubmitRecord> records = evaluator.resultsToSubmitRecord(null, terms);
+    SubmitRecord record = evaluator.resultsToSubmitRecord(null, terms);
 
     // assert that
-    SubmitRecord record1Expected = new SubmitRecord();
-    record1Expected.status = SubmitRecord.Status.OK;
-    record1Expected.labels = new ArrayList<>();
-    record1Expected.labels.add(submitRecordLabel2);
+    SubmitRecord expectedRecord = new SubmitRecord();
+    expectedRecord.status = SubmitRecord.Status.OK;
+    expectedRecord.labels = new ArrayList<>();
+    expectedRecord.labels.add(submitRecordLabel2);
+    expectedRecord.labels.add(submitRecordLabel3);
 
-    SubmitRecord record2Expected = new SubmitRecord();
-    record2Expected.status = SubmitRecord.Status.OK;
-    record2Expected.labels = new ArrayList<>();
-    record2Expected.labels.add(submitRecordLabel3);
-
-    assertThat(records).hasSize(2);
-
-    assertThat(records.get(0)).isEqualTo(record1Expected);
-    assertThat(records.get(1)).isEqualTo(record2Expected);
+    assertThat(record).isEqualTo(expectedRecord);
   }
 
   private static Term makeTerm(String status, StructureTerm labels) {
@@ -149,12 +142,12 @@
   }
 
   private ChangeData makeChangeData() {
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
     cd.setChange(TestChanges.newChange(project, admin.id()));
     return cd;
   }
 
   private PrologRuleEvaluator makeEvaluator() {
-    return evaluatorFactory.create(makeChangeData(), SubmitRuleOptions.defaults());
+    return evaluatorFactory.create(makeChangeData(), PrologOptions.defaultOptions());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 536cbbb..e5432d1 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -19,8 +19,9 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -40,6 +41,7 @@
   private static final String RULE_TEMPLATE =
       "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Before
@@ -84,7 +86,7 @@
   }
 
   private SubmitRecord.Status statusForRule() throws Exception {
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 9fb7c2d..4fe0df4 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -20,102 +20,91 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.List;
-import org.junit.After;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-
-  private ChangeIndexedCounter changeIndexedCounter;
-  private RegistrationHandle changeIndexedCounterHandle;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   /** @param injector injector */
   public void configureIndex(Injector injector) {}
 
-  @Before
-  public void addChangeIndexedCounter() {
-    changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
-  }
-
-  @After
-  public void removeChangeIndexedCounter() {
-    if (changeIndexedCounterHandle != null) {
-      changeIndexedCounterHandle.remove();
-    }
-  }
-
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
   public void indexChange() throws Exception {
-    configureIndex(server.getTestInjector());
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      configureIndex(server.getTestInjector());
 
-    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
-    String changeId = change.getChangeId();
-    String changeLegacyId = change.getChange().getId().toString();
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+      PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+      String changeId = change.getChangeId();
+      String changeLegacyId = change.getChange().getId().toString();
+      ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-    disableChangeIndexWrites();
-    amendChange(changeId, "second test", "test2.txt", "test2");
+      disableChangeIndexWrites();
+      amendChange(changeId, "second test", "test2.txt", "test2");
 
-    assertChangeQuery(change.getChange(), false);
-    enableChangeIndexWrites();
+      assertChangeQuery(change.getChange(), false);
+      enableChangeIndexWrites();
 
-    changeIndexedCounter.clear();
-    String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
-    adminSshSession.exec(cmd);
-    adminSshSession.assertSuccess();
+      changeIndexedCounter.clear();
+      String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
+      adminSshSession.exec(cmd);
+      adminSshSession.assertSuccess();
 
-    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+      changeIndexedCounter.assertReindexOf(changeInfo, 1);
 
-    assertChangeQuery(change.getChange(), true);
+      assertChangeQuery(change.getChange(), true);
+    }
   }
 
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
   public void indexProject() throws Exception {
-    configureIndex(server.getTestInjector());
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      configureIndex(server.getTestInjector());
 
-    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
-    String changeId = change.getChangeId();
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+      PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+      String changeId = change.getChangeId();
+      ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-    disableChangeIndexWrites();
-    amendChange(changeId, "second test", "test2.txt", "test2");
+      disableChangeIndexWrites();
+      amendChange(changeId, "second test", "test2.txt", "test2");
 
-    assertChangeQuery(change.getChange(), false);
-    enableChangeIndexWrites();
+      assertChangeQuery(change.getChange(), false);
+      enableChangeIndexWrites();
 
-    changeIndexedCounter.clear();
-    String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
-    adminSshSession.exec(cmd);
-    adminSshSession.assertSuccess();
-
-    boolean indexing = true;
-    while (indexing) {
-      String out = adminSshSession.exec("gerrit show-queue --wide");
+      changeIndexedCounter.clear();
+      String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
+      adminSshSession.exec(cmd);
       adminSshSession.assertSuccess();
-      indexing = out.contains("Index all changes of project " + project.get());
+
+      boolean indexing = true;
+      while (indexing) {
+        String out = adminSshSession.exec("gerrit show-queue --wide");
+        adminSshSession.assertSuccess();
+        indexing = out.contains("Index all changes of project " + project.get());
+      }
+
+      changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+      assertChangeQuery(change.getChange(), true);
     }
-
-    changeIndexedCounter.assertReindexOf(changeInfo, 1);
-
-    assertChangeQuery(change.getChange(), true);
   }
 
   private void assertChangeQuery(ChangeData change, boolean assertTrue) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 0ce05b0..39dbaa7 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -18,7 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.project.ProjectState;
 import org.junit.Test;
 
@@ -33,7 +33,7 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
   }
 
@@ -46,7 +46,7 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
     adminSshSession.assertFailure();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNull();
   }
 
@@ -62,7 +62,7 @@
             + newProjectName
             + ".git");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertThat(projectState.getName()).isEqualTo(newProjectName);
   }
@@ -79,7 +79,7 @@
             + newProjectName
             + "/");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertThat(projectState.getName()).isEqualTo(newProjectName);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
new file mode 100644
index 0000000..434071f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Tests for a defaulted custom index configuration. This unknown type is the opposite of {@link
+ * IndexType#getKnownTypes()}.
+ */
+public class CustomIndexIT extends AbstractIndexTests {
+
+  @ConfigSuite.Default
+  public static Config customIndexType() {
+    Config config = new Config();
+    config.setString("index", null, "type", "custom");
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 620cd09..b539bf8 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -18,10 +18,12 @@
 import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
 import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.index.IndexType;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 
+/** Tests for every supported {@link IndexType#isElasticsearch()} most recent index version. */
 public class ElasticIndexIT extends AbstractIndexTests {
 
   @ConfigSuite.Default
diff --git a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index c23f889d..9dcfa3f 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GarbageCollectionResult;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GarbageCollectionQueue;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
index 370fb72..7062b00 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
@@ -14,4 +14,7 @@
 
 package com.google.gerrit.acceptance.ssh;
 
+import com.google.gerrit.index.IndexType;
+
+/** Tests for the default {@link IndexType#isLucene()} index configuration. */
 public class IndexIT extends AbstractIndexTests {}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index e61e2cc..4bf7c19 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 0f47a4a..78960bb 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -294,8 +295,8 @@
     // computation while formatting the output, such as labels, reviewers etc.
     merge(r);
     for (ListChangesOption option : ListChangesOption.values()) {
-      assertThat(gApi.changes().query(r.getChangeId()).withOption(option).get())
-          .named("Option: " + option)
+      assertWithMessage("Option: " + option)
+          .that(gApi.changes().query(r.getChangeId()).withOption(option).get())
           .hasSize(1);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 6998a0a..5a31bfd 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.testing.ConfigSuite;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index 9c1e23d..ae45d90 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -16,79 +16,94 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.CreateProjectArgs;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import org.junit.After;
-import org.junit.Before;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
 @UseSsh
 public class SshTraceIT extends AbstractDaemonTest {
-  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
-
-  private TraceValidatingProjectCreationValidationListener projectCreationListener;
-  private RegistrationHandle projectCreationListenerRegistrationHandle;
-
-  @Before
-  public void setup() {
-    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
-    projectCreationListenerRegistrationHandle =
-        projectCreationValidationListeners.add("gerrit", projectCreationListener);
-  }
-
-  @After
-  public void cleanup() {
-    projectCreationListenerRegistrationHandle.remove();
-  }
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void sshCallWithoutTrace() throws Exception {
-    adminSshSession.exec("gerrit create-project new1");
-    adminSshSession.assertSuccess();
-    assertThat(projectCreationListener.traceId).isNull();
-    assertThat(projectCreationListener.foundTraceId).isFalse();
-    assertThat(projectCreationListener.isLoggingForced).isFalse();
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project new1");
+      adminSshSession.assertSuccess();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.foundTraceId).isFalse();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+    }
   }
 
   @Test
   public void sshCallWithTrace() throws Exception {
-    adminSshSession.exec("gerrit create-project --trace new2");
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project --trace new2");
 
-    // The trace ID is written to stderr.
-    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+      // The trace ID is written to stderr.
+      adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
 
-    assertThat(projectCreationListener.traceId).isNotNull();
-    assertThat(projectCreationListener.foundTraceId).isTrue();
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.traceId).isNotNull();
+      assertThat(projectCreationListener.foundTraceId).isTrue();
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+    }
   }
 
   @Test
   public void sshCallWithTraceAndProvidedTraceId() throws Exception {
-    adminSshSession.exec("gerrit create-project --trace --trace-id issue/123 new3");
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project --trace --trace-id issue/123 new3");
 
-    // The trace ID is written to stderr.
-    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+      // The trace ID is written to stderr.
+      adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
 
-    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.foundTraceId).isTrue();
-    assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+      assertThat(projectCreationListener.foundTraceId).isTrue();
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+    }
   }
 
   @Test
   public void sshCallWithTraceIdAndWithoutTraceFails() throws Exception {
-    adminSshSession.exec("gerrit create-project --trace-id issue/123 new3");
+    adminSshSession.exec("gerrit create-project --trace-id issue/123 new4");
     adminSshSession.assertFailure("A trace ID can only be set if --trace was specified.");
   }
 
+  @Test
+  public void performanceLoggingForSshCall() throws Exception {
+    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testPerformanceLogger)) {
+      adminSshSession.exec("gerrit create-project new5");
+      adminSshSession.assertSuccess();
+      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+    }
+  }
+
   private static class TraceValidatingProjectCreationValidationListener
       implements ProjectCreationValidationListener {
     String traceId;
@@ -103,4 +118,28 @@
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
     }
   }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index e0d6593..96864d9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -15,19 +15,20 @@
 package com.google.gerrit.acceptance.testsuite.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
 import java.util.Objects;
@@ -217,7 +218,7 @@
 
   @Test
   public void notExistingGroupCanBeCheckedForExistence() throws Exception {
-    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+    AccountGroup.UUID notExistingGroupUuid = AccountGroup.uuid("not-existing-group");
 
     boolean exists = groupOperations.group(notExistingGroupUuid).exists();
 
@@ -226,10 +227,9 @@
 
   @Test
   public void retrievingNotExistingGroupFails() throws Exception {
-    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
-
-    exception.expect(IllegalStateException.class);
-    groupOperations.group(notExistingGroupUuid).get();
+    AccountGroup.UUID notExistingGroupUuid = AccountGroup.uuid("not-existing-group");
+    assertThrows(
+        IllegalStateException.class, () -> groupOperations.group(notExistingGroupUuid).get());
   }
 
   @Test
@@ -270,7 +270,7 @@
 
     AccountGroup.NameKey groupName = groupOperations.group(groupUuid).get().nameKey();
 
-    assertThat(groupName).isEqualTo(new AccountGroup.NameKey("ABC-789-this-name-must-be-unique"));
+    assertThat(groupName).isEqualTo(AccountGroup.nameKey("ABC-789-this-name-must-be-unique"));
   }
 
   @Test
@@ -297,7 +297,7 @@
 
   @Test
   public void ownerGroupUuidOfExistingGroupCanBeRetrieved() throws Exception {
-    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("owner group");
+    AccountGroup.UUID originalOwnerGroupUuid = AccountGroup.uuid("owner group");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
 
@@ -314,14 +314,16 @@
     TestGroup visibleGroup = groupOperations.group(visibleGroupUuid).get();
     TestGroup invisibleGroup = groupOperations.group(invisibleGroupUuid).get();
 
-    assertThat(visibleGroup.visibleToAll()).named("visibility of visible group").isTrue();
-    assertThat(invisibleGroup.visibleToAll()).named("visibility of invisible group").isFalse();
+    assertWithMessage("visibility of visible group").that(visibleGroup.visibleToAll()).isTrue();
+    assertWithMessage("visibility of invisible group")
+        .that(invisibleGroup.visibleToAll())
+        .isFalse();
   }
 
   @Test
   public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
     GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(group.id);
 
     Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
 
@@ -330,9 +332,9 @@
 
   @Test
   public void membersOfExistingGroupCanBeRetrieved() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
+    Account.Id memberId3 = Account.id(3000);
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().members(memberId1, memberId2, memberId3).create();
 
@@ -352,9 +354,9 @@
 
   @Test
   public void subgroupsOfExistingGroupCanBeRetrieved() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2, subgroupUuid3).create();
 
@@ -428,11 +430,11 @@
 
   @Test
   public void ownerGroupUuidCanBeUpdated() throws Exception {
-    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("original owner");
+    AccountGroup.UUID originalOwnerGroupUuid = AccountGroup.uuid("original owner");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
 
-    AccountGroup.UUID updatedOwnerGroupUuid = new AccountGroup.UUID("updated owner");
+    AccountGroup.UUID updatedOwnerGroupUuid = AccountGroup.uuid("updated owner");
     groupOperations.group(groupUuid).forUpdate().ownerGroupUuid(updatedOwnerGroupUuid).update();
 
     AccountGroup.UUID currentOwnerGroupUuid =
@@ -454,8 +456,8 @@
   public void membersCanBeAdded() throws Exception {
     AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
 
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     groupOperations.group(groupUuid).forUpdate().addMember(memberId1).addMember(memberId2).update();
 
     ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
@@ -464,8 +466,8 @@
 
   @Test
   public void membersCanBeRemoved() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
     groupOperations.group(groupUuid).forUpdate().removeMember(memberId2).update();
@@ -476,11 +478,11 @@
 
   @Test
   public void memberAdditionAndRemovalCanBeMixed() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId3 = Account.id(3000);
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -494,8 +496,8 @@
 
   @Test
   public void membersCanBeCleared() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
     groupOperations.group(groupUuid).forUpdate().clearMembers().update();
@@ -506,11 +508,11 @@
 
   @Test
   public void furtherMembersCanBeAddedAfterClearingAll() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId3 = Account.id(3000);
     groupOperations.group(groupUuid).forUpdate().clearMembers().addMember(memberId3).update();
 
     ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
@@ -521,8 +523,8 @@
   public void subgroupsCanBeAdded() throws Exception {
     AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
 
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -536,8 +538,8 @@
 
   @Test
   public void subgroupsCanBeRemoved() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
@@ -549,12 +551,12 @@
 
   @Test
   public void subgroupAdditionAndRemovalCanBeMixed() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -568,8 +570,8 @@
 
   @Test
   public void subgroupsCanBeCleared() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
@@ -581,12 +583,12 @@
 
   @Test
   public void furtherSubgroupsCanBeAddedAfterClearingAll() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -610,37 +612,31 @@
 
   private AccountGroup.UUID createGroupInServer(GroupInput input) throws RestApiException {
     GroupInfo group = gApi.groups().create(input).detail();
-    return new AccountGroup.UUID(group.id);
+    return AccountGroup.uuid(group.id);
   }
 
   private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<AccountInfo, Account.Id>() {
-          @Override
-          public boolean apply(AccountInfo actualAccount, Account.Id expectedId) {
-            Account.Id accountId =
-                Optional.ofNullable(actualAccount)
-                    .map(account -> account._accountId)
-                    .map(Account.Id::new)
-                    .orElse(null);
-            return Objects.equals(accountId, expectedId);
-          }
+        (actualAccount, expectedId) -> {
+          Account.Id accountId =
+              Optional.ofNullable(actualAccount)
+                  .map(account -> account._accountId)
+                  .map(Account::id)
+                  .orElse(null);
+          return Objects.equals(accountId, expectedId);
         },
         "has ID");
   }
 
   private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<GroupInfo, AccountGroup.UUID>() {
-          @Override
-          public boolean apply(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
-            AccountGroup.UUID groupUuid =
-                Optional.ofNullable(actualGroup)
-                    .map(group -> group.id)
-                    .map(AccountGroup.UUID::new)
-                    .orElse(null);
-            return Objects.equals(groupUuid, expectedUuid);
-          }
+        (actualGroup, expectedUuid) -> {
+          AccountGroup.UUID groupUuid =
+              Optional.ofNullable(actualGroup)
+                  .map(group -> group.id)
+                  .map(AccountGroup::uuid)
+                  .orElse(null);
+          return Objects.equals(groupUuid, expectedUuid);
         },
         "has UUID");
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 3f537c0..c7a8eb6 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -15,14 +15,41 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 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.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
 import org.junit.Test;
 
 public class ProjectOperationsImplTest extends AbstractDaemonTest {
@@ -48,9 +75,517 @@
   @Test
   public void emptyCommit() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
+
     List<BranchInfo> branches = gApi.projects().name(key.get()).branches().get();
     assertThat(branches).isNotEmpty();
     assertThat(branches.stream().map(x -> x.ref).collect(toList()))
         .isEqualTo(ImmutableList.of("HEAD", "refs/meta/config", "refs/heads/master"));
   }
+
+  @Test
+  public void getProjectConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEqualTo("my fancy project");
+  }
+
+  @Test
+  public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    ProjectState cachedProjectState1 = projectCache.checkedGet(key);
+    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
+    assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
+    assertThat(projectConfig.getProject().getDescription()).isEmpty();
+    projectConfig.getProject().setDescription("my fancy project");
+
+    ProjectConfig cachedProjectConfig2 = projectCache.checkedGet(key).getConfig();
+    assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
+  }
+
+  @Test
+  public void getProjectConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    assertThat(projectConfig.getName()).isEqualTo(key);
+    assertThat(projectConfig.getRevision()).isNull();
+  }
+
+  @Test
+  public void getConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).text().isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).sections().containsExactly("project");
+    assertThat(config).subsections("project").isEmpty();
+    assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
+  }
+
+  @Test
+  public void getConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).isEmpty();
+  }
+
+  @Test
+  public void addAllowPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDenyPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(deny(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "deny group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(block(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "block group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowForcePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).force(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "+force group global:Registered-Users");
+  }
+
+  @Test
+  public void updateExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "exclusiveGroupPermissions", "abandon");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addMultipleExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .setExclusiveGroup(permissionKey(Permission.CREATE).ref("refs/foo"), true)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "abandon create");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "create");
+  }
+
+  @Test
+  public void addMultiplePermissions() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Project-Owners",
+            "create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDuplicatePermissions() throws Exception {
+    TestPermission permission =
+        TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations.project(key).forUpdate().add(permission).add(permission).update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+
+    projectOperations.project(key).forUpdate().add(permission).update();
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "label-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelAsPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2)
+                .impersonation(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("labelAs-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowCapability() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config)
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("administrateServer", "group Registered Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithRange() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 5000))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("queryLimit", "+0..+5000 group Registered Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithDefaultRange() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("queryLimit", "+0..+" + DEFAULT_MAX_QUERY_LIMIT + " group Registered Users");
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Project-Owners");
+  }
+
+  @Test
+  public void removeLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabel("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "label-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
+  public void removeCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .add(allowCapability(ADMINISTRATE_SERVER).group(PROJECT_OWNERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "administrateServer", "group Registered Users",
+                "administrateServer", "group Project Owners"));
+
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+  }
+
+  @Test
+  public void removeOnePermissionForAllGroupsFromOneAccessSection() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "abandon", "group global:Project-Owners",
+                "abandon", "group global:Registered-Users",
+                "create", "group global:Registered-Users"));
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo"))
+        .update();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).subsectionValues("access", "refs/foo").doesNotContainKey("abandon");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .update());
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .update());
+  }
+
+  private void deleteRefsMetaConfig(Project.NameKey key) throws Exception {
+    try (Repository repo = repoManager.openRepository(key);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.delete(REFS_CONFIG);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
new file mode 100644
index 0000000..8fc1677
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.project;
+
+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.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermissionKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import java.util.function.Function;
+import org.junit.Test;
+
+public class TestProjectUpdateTest {
+  private static final AllProjectsName ALL_PROJECTS_NAME = new AllProjectsName("All-Projects");
+
+  @Test
+  public void testCapabilityDisallowsZeroRangeOnCapabilityThatHasNoRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testCapabilityAllowsZeroRangeOnCapabilityThatHasRange() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 0).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testCapabilityDisallowsRangeIfCapabilityDoesNotSupportRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(-1, 1).build());
+  }
+
+  @Test
+  public void testCapabilityRangeIsZeroIfCapabilityDoesNotSupportRange() throws Exception {
+    TestCapability c = allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityUsesDefaultRangeIfUnspecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_QUERY_LIMIT);
+
+    c = allowCapability(BATCH_CHANGES_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_BATCH_CHANGES_LIMIT);
+  }
+
+  @Test
+  public void testCapabilityUsesExplicitRangeIfSpecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(5, 20).build();
+    assertThat(c.min()).isEqualTo(5);
+    assertThat(c.max()).isEqualTo(20);
+  }
+
+  @Test
+  public void testLabelPermissionRequiresValidLabelName() throws Exception {
+    Function<String, TestLabelPermission.Builder> labelBuilder =
+        name -> allowLabel(name).ref("refs/*").group(REGISTERED_USERS).range(-1, 1);
+    assertThat(labelBuilder.apply("Code-Review").build().name()).isEqualTo("Code-Review");
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsZeroRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testPermissionKeyRequiresValidRefName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        ref -> permissionKey(ABANDON).ref(ref).group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("refs/*").build().section()).isEqualTo("refs/*");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("foo").build());
+  }
+
+  @Test
+  public void testLabelPermissionKeyRequiresValidLabelName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        label -> labelPermissionKey(label).ref("refs/*").group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("Code-Review").build().name()).isEqualTo("label-Code-Review");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testPermissionKeyDisallowsSettingRefOnGlobalCapability() throws Exception {
+    assertThrows(RuntimeException.class, () -> capabilityKey(ADMINISTRATE_SERVER).ref("refs/*"));
+  }
+
+  @Test
+  public void testProjectUpdateDisallowsGroupOnExclusiveGroupPermissionKey() throws Exception {
+    TestPermissionKey.Builder b = permissionKey(ABANDON).ref("refs/*");
+    Function<TestPermissionKey.Builder, TestProjectUpdate.Builder> updateBuilder =
+        kb -> builder().setExclusiveGroup(kb, true);
+
+    assertThat(updateBuilder.apply(b).build().exclusiveGroupPermissions())
+        .containsExactly(b.build(), true);
+
+    b.group(REGISTERED_USERS);
+    assertThrows(RuntimeException.class, () -> updateBuilder.apply(b).build());
+  }
+
+  @Test
+  public void hasCapabilityUpdates() throws Exception {
+    assertThat(builder().build().hasCapabilityUpdates()).isFalse();
+    assertThat(
+            builder()
+                .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+                .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 1))
+                .remove(permissionKey(ABANDON).ref("refs/foo"))
+                .remove(labelPermissionKey("Code-Review").ref("refs/foo"))
+                .setExclusiveGroup(permissionKey(ABANDON).ref("refs/bar"), true)
+                .setExclusiveGroup(labelPermissionKey(ABANDON).ref("refs/bar"), true)
+                .build()
+                .hasCapabilityUpdates())
+        .isFalse();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS)).update());
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().remove(capabilityKey(ADMINISTRATE_SERVER)).update());
+  }
+
+  private static TestProjectUpdate.Builder builder() {
+    return builder(Project.nameKey("test-project"));
+  }
+
+  private static TestProjectUpdate.Builder builder(Project.NameKey nameKey) {
+    return TestProjectUpdate.builder(nameKey, ALL_PROJECTS_NAME, u -> {});
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index 5cbed1b..f6421a5 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.acceptance.testsuite.request;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -24,8 +25,8 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.CurrentUser.PropertyKey;
@@ -67,12 +68,9 @@
   @Test
   public void setApiUserToNonExistingUser() throws Exception {
     fastCheckCurrentUser(admin.id());
-    try {
-      requestScopeOperations.setApiUser(new Account.Id(sequences.nextAccountId()));
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
+    assertThrows(
+        RuntimeException.class,
+        () -> requestScopeOperations.setApiUser(Account.id(sequences.nextAccountId())));
     checkCurrentUser(admin.id());
   }
 
@@ -98,24 +96,26 @@
 
   private void fastCheckCurrentUser(Account.Id expected) {
     // Check current user quickly, since the full check requires creating changes and is quite slow.
-    assertThat(userProvider.get().isIdentifiedUser())
-        .named("user from provider is an IdentifiedUser")
+    assertWithMessage("user from provider is an IdentifiedUser")
+        .that(userProvider.get().isIdentifiedUser())
         .isTrue();
-    assertThat(userProvider.get().getAccountId()).named("user from provider").isEqualTo(expected);
+    assertWithMessage("user from provider")
+        .that(userProvider.get().getAccountId())
+        .isEqualTo(expected);
   }
 
   private void checkCurrentUser(Account.Id expected) throws Exception {
     // Test all supported ways that an acceptance test might query the active user.
     fastCheckCurrentUser(expected);
-    assertThat(gApi.accounts().self().get()._accountId)
-        .named("user from GerritApi")
+    assertWithMessage("user from GerritApi")
+        .that(gApi.accounts().self().get()._accountId)
         .isEqualTo(expected.get());
     AcceptanceTestRequestScope.Context ctx = atrScope.get();
-    assertThat(ctx.getUser().isIdentifiedUser())
-        .named("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
+    assertWithMessage("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
+        .that(ctx.getUser().isIdentifiedUser())
         .isTrue();
-    assertThat(ctx.getUser().getAccountId())
-        .named("user from AcceptanceTestRequestScope.Context")
+    assertWithMessage("user from AcceptanceTestRequestScope.Context")
+        .that(ctx.getUser().getAccountId())
         .isEqualTo(expected);
     checkSshUser(expected);
   }
@@ -131,8 +131,8 @@
     assertThat(gApi.changes().id(changeId).get().owner._accountId).isEqualTo(expected.get());
     String queryResults =
         atrScope.get().getSession().exec("gerrit query owner:self change:" + changeId);
-    assertThat(findDistinct(queryResults, "I[0-9a-f]{40}"))
-        .named("Change-Ids in query results:\n%s", queryResults)
+    assertWithMessage("Change-Ids in query results:\n%s", queryResults)
+        .that(findDistinct(queryResults, "I[0-9a-f]{40}"))
         .containsExactly(changeId);
   }
 
diff --git a/javatests/com/google/gerrit/common/AutoValueTest.java b/javatests/com/google/gerrit/common/AutoValueTest.java
index 89d7bf4..947fe4a 100644
--- a/javatests/com/google/gerrit/common/AutoValueTest.java
+++ b/javatests/com/google/gerrit/common/AutoValueTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class AutoValueTest extends GerritBaseTests {
+public class AutoValueTest {
   @AutoValue
   abstract static class Auto {
     static Auto create(String val) {
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index 18ececd..c7b21a3 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -8,7 +8,6 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common:version",
         "//java/com/google/gerrit/launcher",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
index faf9d6c..e775cbc 100644
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import org.junit.Before;
 import org.junit.Test;
 
-public class AccessSectionTest extends GerritBaseTests {
+public class AccessSectionTest {
   private static final String REF_PATTERN = "refs/heads/master";
 
   private AccessSection accessSection;
@@ -57,16 +57,17 @@
     Permission submitPermission = new Permission(Permission.SUBMIT);
     accessSection.setPermissions(ImmutableList.of(submitPermission));
     assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
-
-    exception.expect(NullPointerException.class);
-    accessSection.setPermissions(null);
+    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
   }
 
   @Test
   public void cannotSetDuplicatePermissions() {
-    exception.expect(IllegalArgumentException.class);
-    accessSection.setPermissions(
-        ImmutableList.of(new Permission(Permission.ABANDON), new Permission(Permission.ABANDON)));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(
+                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
   }
 
   @Test
@@ -76,9 +77,11 @@
     Permission abandonPermissionUpperCase =
         new Permission(Permission.ABANDON.toUpperCase(Locale.US));
 
-    exception.expect(IllegalArgumentException.class);
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
   }
 
   @Test
@@ -92,9 +95,7 @@
     Permission submitPermission = new Permission(Permission.SUBMIT);
     accessSection.setPermissions(ImmutableList.of(submitPermission));
     assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-
-    exception.expect(NullPointerException.class);
-    accessSection.getPermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
   }
 
   @Test
@@ -112,8 +113,7 @@
     assertThat(accessSection.getPermission(Permission.SUBMIT, true))
         .isEqualTo(new Permission(Permission.SUBMIT));
 
-    exception.expect(NullPointerException.class);
-    accessSection.getPermission(null, true);
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
   }
 
   @Test
@@ -130,9 +130,7 @@
     assertThat(accessSection.getPermissions())
         .containsExactly(abandonPermission, rebasePermission, submitPermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.addPermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
   }
 
   @Test
@@ -166,9 +164,7 @@
     assertThat(accessSection.getPermissions())
         .containsExactly(abandonPermission, rebasePermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.remove(null);
+    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
   }
 
   @Test
@@ -187,8 +183,7 @@
         .containsExactly(abandonPermission, rebasePermission)
         .inOrder();
 
-    exception.expect(NullPointerException.class);
-    accessSection.removePermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
   }
 
   @Test
@@ -229,9 +224,7 @@
     assertThat(accessSection1.getPermissions())
         .containsExactly(abandonPermission, rebasePermission, submitPermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.mergeFrom(null);
+    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/common/data/BUILD b/javatests/com/google/gerrit/common/data/BUILD
index 776a5e0..f2b7d63 100644
--- a/javatests/com/google/gerrit/common/data/BUILD
+++ b/javatests/com/google/gerrit/common/data/BUILD
@@ -5,7 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index 3dd2db3..dcd3c05 100644
--- a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EncodePathSeparatorTest extends GerritBaseTests {
+public class EncodePathSeparatorTest {
   @Test
   public void defaultBehaviour() {
     assertThat(new GitwebType().replacePathSeparator("a/b")).isEqualTo("a/b");
diff --git a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
index 055f57d..ec71e05 100644
--- a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class FilenameComparatorTest extends GerritBaseTests {
+public class FilenameComparatorTest {
   private FilenameComparator comparator = FilenameComparator.INSTANCE;
 
   @Test
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 8cf486b..25b55c7 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
 import org.junit.Test;
 
-public class GroupReferenceTest extends GerritBaseTests {
+public class GroupReferenceTest {
   @Test
   public void forGroupDescription() {
     String name = "foo";
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     GroupReference groupReference =
         GroupReference.forGroup(
             new GroupDescription.Basic() {
@@ -56,7 +56,7 @@
 
   @Test
   public void create() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
@@ -68,15 +68,15 @@
     // GroupReferences where the UUID is null are used to represent groups from project.config that
     // cannot be resolved.
     String name = "foo";
-    GroupReference groupReference = new GroupReference(null, name);
+    GroupReference groupReference = new GroupReference(name);
     assertThat(groupReference.getUUID()).isNull();
     assertThat(groupReference.getName()).isEqualTo(name);
   }
 
   @Test
   public void cannotCreateWithoutName() {
-    exception.expect(NullPointerException.class);
-    new GroupReference(new AccountGroup.UUID("uuid"), null);
+    assertThrows(
+        NullPointerException.class, () -> new GroupReference(AccountGroup.uuid("uuid"), null));
   }
 
   @Test
@@ -99,12 +99,12 @@
 
   @Test
   public void getAndSetUuid() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
 
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
     groupReference.setUUID(uuid2);
     assertThat(groupReference.getUUID()).isEqualTo(uuid2);
 
@@ -116,7 +116,7 @@
 
   @Test
   public void getAndSetName() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getName()).isEqualTo(name);
@@ -125,21 +125,20 @@
     groupReference.setName(name2);
     assertThat(groupReference.getName()).isEqualTo(name2);
 
-    exception.expect(NullPointerException.class);
-    groupReference.setName(null);
+    assertThrows(NullPointerException.class, () -> groupReference.setName(null));
   }
 
   @Test
   public void toConfigValue() {
     String name = "foo";
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-foo"), name);
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-foo"), name);
     assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
   }
 
   @Test
   public void testEquals() {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid-foo");
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid-foo");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
     String name1 = "foo";
     String name2 = "bar";
 
@@ -154,12 +153,11 @@
 
   @Test
   public void testHashcode() {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid1");
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
     assertThat(new GroupReference(uuid1, "foo").hashCode())
         .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
 
     // Check that the following calls don't fail with an exception.
-    new GroupReference(null, "bar").hashCode();
-    new GroupReference(new AccountGroup.UUID(null), "bar").hashCode();
+    new GroupReference("bar").hashCode();
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
index a534a9e..6f5232b 100644
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
@@ -17,23 +17,22 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.testing.GerritBaseTests;
-import java.sql.Date;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
-public class LabelFunctionTest extends GerritBaseTests {
+public class LabelFunctionTest {
   private static final String LABEL_NAME = "Verified";
-  private static final LabelId LABEL_ID = new LabelId(LABEL_NAME);
-  private static final Change.Id CHANGE_ID = new Change.Id(100);
-  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
   private static final LabelType VERIFIED_LABEL = makeLabel();
   private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
   private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
@@ -82,7 +81,7 @@
     SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 
   private static LabelType makeLabel() {
@@ -97,14 +96,11 @@
   }
 
   private static PatchSetApproval makeApproval(int value) {
-    Account.Id accountId = new Account.Id(10000 + value);
-    PatchSetApproval.Key key = makeKey(PS_ID, accountId, LABEL_ID);
-    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
-  }
-
-  private static PatchSetApproval.Key makeKey(
-      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
-    return new PatchSetApproval.Key(psId, accountId, labelId);
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
   }
 
   private static void checkBlockWorks(LabelFunction function) {
@@ -113,7 +109,7 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
   }
 
   private static void checkNothingHappens(LabelFunction function) {
@@ -144,6 +140,6 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
index db0df2e..6c3befb 100644
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LabelTypeTest extends GerritBaseTests {
+public class LabelTypeTest {
   @Test
   public void sortLabelValues() {
     LabelValue v0 = new LabelValue((short) 0, "Zero");
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index b22a511..b646d2b 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.Test;
 
-public class ParameterizedStringTest extends GerritBaseTests {
+public class ParameterizedStringTest {
   @Test
   public void emptyString() {
     ParameterizedString p = new ParameterizedString("");
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
index f442f39..d815dbc 100644
--- a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
+++ b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.AccountGroup;
 import org.junit.Before;
 import org.junit.Test;
 
-public class PermissionRuleTest extends GerritBaseTests {
+public class PermissionRuleTest {
   private GroupReference groupReference;
   private PermissionRule permissionRule;
 
   @Before
   public void setup() {
-    this.groupReference = new GroupReference(new AccountGroup.UUID("uuid"), "group");
+    this.groupReference = new GroupReference(AccountGroup.uuid("uuid"), "group");
     this.permissionRule = new PermissionRule(groupReference);
   }
 
@@ -42,8 +42,7 @@
 
   @Test
   public void cannotSetActionToNull() {
-    exception.expect(NullPointerException.class);
-    permissionRule.setAction(null);
+    assertThrows(NullPointerException.class, () -> permissionRule.setAction(null));
   }
 
   @Test
@@ -131,7 +130,7 @@
 
   @Test
   public void setGroup() {
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     assertThat(groupReference2).isNotEqualTo(groupReference);
 
     assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
@@ -142,10 +141,10 @@
 
   @Test
   public void mergeFromAnyBlock() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -170,10 +169,10 @@
 
   @Test
   public void mergeFromAnyDeny() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -193,10 +192,10 @@
 
   @Test
   public void mergeFromAnyBatch() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -216,10 +215,10 @@
 
   @Test
   public void mergeFromAnyForce() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -239,11 +238,11 @@
 
   @Test
   public void mergeFromMergeRange() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
     permissionRule1.setRange(-1, 2);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
     permissionRule2.setRange(-2, 1);
 
@@ -256,10 +255,10 @@
 
   @Test
   public void mergeFromGroupNotChanged() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -348,7 +347,7 @@
 
   @Test
   public void testEquals() {
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
     assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
 
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
index 23380e7..1012eff 100644
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -17,14 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.AccountGroup;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 
-public class PermissionTest extends GerritBaseTests {
+public class PermissionTest {
   private static final String PERMISSION_NAME = "foo";
 
   private Permission permission;
@@ -155,14 +154,14 @@
   @Test
   public void setAndGetRules() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
     assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
 
     PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
     permission.setRules(ImmutableList.of(permissionRule3));
     assertThat(permission.getRules()).containsExactly(permissionRule3);
   }
@@ -170,10 +169,10 @@
   @Test
   public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
 
     List<PermissionRule> rules = new ArrayList<>();
     rules.add(permissionRule1);
@@ -188,14 +187,14 @@
 
   @Test
   public void getNonExistingRule() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     assertThat(permission.getRule(groupReference)).isNull();
     assertThat(permission.getRule(groupReference, false)).isNull();
   }
 
   @Test
   public void getRule() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     PermissionRule permissionRule = new PermissionRule(groupReference);
     permission.setRules(ImmutableList.of(permissionRule));
     assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
@@ -203,7 +202,7 @@
 
   @Test
   public void createMissingRuleOnGet() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     assertThat(permission.getRule(groupReference)).isNull();
 
     assertThat(permission.getRule(groupReference, true))
@@ -213,11 +212,11 @@
   @Test
   public void addRule() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     assertThat(permission.getRule(groupReference3)).isNull();
 
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
@@ -231,10 +230,10 @@
   @Test
   public void removeRule() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
@@ -248,10 +247,10 @@
   @Test
   public void removeRuleByGroupReference() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
@@ -265,9 +264,9 @@
   @Test
   public void clearRules() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
     assertThat(permission.getRules()).isNotEmpty();
@@ -279,11 +278,11 @@
   @Test
   public void mergePermissions() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
 
     Permission permission1 = new Permission("foo");
     permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
@@ -300,9 +299,9 @@
   @Test
   public void testEquals() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
 
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
index 5b9fde7..5386b87 100644
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.junit.Test;
 
-public class SubmitRecordTest extends GerritBaseTests {
+public class SubmitRecordTest {
   private static final SubmitRecord OK_RECORD;
   private static final SubmitRecord FORCED_RECORD;
   private static final SubmitRecord NOT_READY_RECORD;
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index c7ae0d0..ab2bb12 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -12,12 +12,11 @@
     deps = [
         "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/server",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:junit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
         "//lib/testcontainers",
         "//lib/testcontainers:testcontainers-elasticsearch",
@@ -29,7 +28,7 @@
     "//java/com/google/gerrit/elasticsearch",
     "//java/com/google/gerrit/testing:gerrit-test-util",
     "//lib/guice",
-    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib:jgit",
 ]
 
 HTTP_TEST_DEPS = [
@@ -86,9 +85,9 @@
         "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
index 9ce1456..7e044c3 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -21,17 +21,17 @@
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.ProvisionException;
 import java.util.Arrays;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class ElasticConfigurationTest extends GerritBaseTests {
+public class ElasticConfigurationTest {
   @Test
   public void singleServerNoOtherConfig() throws Exception {
     Config cfg = newConfig();
@@ -121,9 +121,9 @@
         .containsExactly(hostURIs);
   }
 
-  private void assertProvisionException(Config cfg) throws Exception {
-    exception.expect(ProvisionException.class);
-    exception.expectMessage("No valid Elasticsearch servers configured");
-    new ElasticConfiguration(cfg);
+  private void assertProvisionException(Config cfg) {
+    ProvisionException thrown =
+        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
+    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index 0203a22..dcc6880 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.TypeLiteral;
@@ -27,7 +26,7 @@
   public static void configure(Config config, ElasticContainer container, String prefix) {
     String hostname = container.getHttpHost().getHostName();
     int port = container.getHttpHost().getPort();
-    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    config.setString("index", null, "type", "elasticsearch");
     config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
     config.setString("elasticsearch", null, "prefix", prefix);
     config.setInt("index", null, "maxLimit", 10000);
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index d4d321d..15d8dd6 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 68c4b71..d734f1e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static java.util.concurrent.TimeUnit.MINUTES;
+
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -28,6 +31,7 @@
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV6QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -55,19 +59,23 @@
     }
   }
 
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @After
-  public void closeIndex() {
+  public void closeIndex() throws Exception {
     // Close the index after each test to prevent exceeding Elasticsearch's
     // shard limit (see Issue 10120).
-    client.execute(
-        new HttpPost(
-            String.format(
-                "http://%s:%d/%s*/_close",
-                container.getHttpHost().getHostName(),
-                container.getHttpHost().getPort(),
-                getSanitizedMethodName())),
-        HttpClientContext.create(),
-        null);
+    client
+        .execute(
+            new HttpPost(
+                String.format(
+                    "http://%s:%d/%s*/_close",
+                    container.getHttpHost().getHostName(),
+                    container.getHttpHost().getPort(),
+                    testName.getSanitizedMethodName())),
+            HttpClientContext.create(),
+            null)
+        .get(5, MINUTES);
   }
 
   @Override
@@ -80,7 +88,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 99c07f4..28d798e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index 89c7774..6658d72 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 52752fb..a015103 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index 7bf72bd..b1de591 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static java.util.concurrent.TimeUnit.MINUTES;
+
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -28,6 +31,7 @@
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -55,19 +59,23 @@
     }
   }
 
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @After
-  public void closeIndex() {
+  public void closeIndex() throws Exception {
     // Close the index after each test to prevent exceeding Elasticsearch's
     // shard limit (see Issue 10120).
-    client.execute(
-        new HttpPost(
-            String.format(
-                "http://%s:%d/%s*/_close",
-                container.getHttpHost().getHostName(),
-                container.getHttpHost().getPort(),
-                getSanitizedMethodName())),
-        HttpClientContext.create(),
-        null);
+    client
+        .execute(
+            new HttpPost(
+                String.format(
+                    "http://%s:%d/%s*/_close",
+                    container.getHttpHost().getHostName(),
+                    container.getHttpHost().getPort(),
+                    testName.getSanitizedMethodName())),
+            HttpClientContext.create(),
+            null)
+        .get(5, MINUTES);
   }
 
   @Override
@@ -80,7 +88,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 96fe274..2e382d4 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 76ec1a2..87a14da 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -57,7 +57,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index e05320a..c9a7a46 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ElasticVersionTest extends GerritBaseTests {
+public class ElasticVersionTest {
   @Test
   public void supportedVersion() throws Exception {
     assertThat(ElasticVersion.forVersion("6.6.0")).isEqualTo(ElasticVersion.V6_6);
@@ -58,10 +58,14 @@
 
   @Test
   public void unsupportedVersion() throws Exception {
-    exception.expect(ElasticVersion.UnsupportedVersion.class);
-    exception.expectMessage(
-        "Unsupported version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
-    ElasticVersion.forVersion("4.0.0");
+    ElasticVersion.UnsupportedVersion thrown =
+        assertThrows(
+            ElasticVersion.UnsupportedVersion.class, () -> ElasticVersion.forVersion("4.0.0"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Unsupported version: [4.0.0]. Supported versions: "
+                + ElasticVersion.supportedVersions());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/javatests/com/google/gerrit/entities/AccountGroupTest.java
similarity index 68%
rename from javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
rename to javatests/com/google/gerrit/entities/AccountGroupTest.java
index 18a55bf..a9d5188 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
+++ b/javatests/com/google/gerrit/entities/AccountGroupTest.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRef;
-import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRefPart;
+import static com.google.gerrit.entities.AccountGroup.UUID.fromRef;
+import static com.google.gerrit.entities.AccountGroup.UUID.fromRefPart;
+import static com.google.gerrit.entities.AccountGroup.uuid;
 
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -70,7 +71,29 @@
     assertThat(fromRefPart("ab/" + TEST_UUID)).isNull();
   }
 
-  private AccountGroup.UUID uuid(String uuid) {
-    return new AccountGroup.UUID(uuid);
+  @Test
+  public void uuidToString() {
+    assertThat(uuid("foo").toString()).isEqualTo("foo");
+    assertThat(uuid("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(uuid("foo:bar").toString()).isEqualTo("foo%3Abar");
+  }
+
+  @Test
+  public void parseUuid() {
+    assertThat(AccountGroup.UUID.parse("foo")).isEqualTo(uuid("foo"));
+    assertThat(AccountGroup.UUID.parse("foo+bar")).isEqualTo(uuid("foo bar"));
+    assertThat(AccountGroup.UUID.parse("foo%3Abar")).isEqualTo(uuid("foo:bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(AccountGroup.id(123).toString()).isEqualTo("123");
+  }
+
+  @Test
+  public void nameKeyToString() {
+    assertThat(AccountGroup.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(AccountGroup.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(AccountGroup.nameKey("foo:bar").toString()).isEqualTo("foo%3Abar");
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java b/javatests/com/google/gerrit/entities/AccountTest.java
similarity index 90%
rename from javatests/com/google/gerrit/reviewdb/client/AccountTest.java
rename to javatests/com/google/gerrit/entities/AccountTest.java
index 11a562f..e8909432 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
+++ b/javatests/com/google/gerrit/entities/AccountTest.java
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.Account.Id.fromRef;
-import static com.google.gerrit.reviewdb.client.Account.Id.fromRefPart;
-import static com.google.gerrit.reviewdb.client.Account.Id.fromRefSuffix;
+import static com.google.gerrit.entities.Account.Id.fromRef;
+import static com.google.gerrit.entities.Account.Id.fromRefPart;
+import static com.google.gerrit.entities.Account.Id.fromRefSuffix;
+import static com.google.gerrit.entities.Account.id;
 
 import org.junit.Test;
 
@@ -90,8 +91,4 @@
     assertThat(fromRefSuffix("12/34")).isEqualTo(id(34));
     assertThat(fromRefSuffix("ab/cd")).isNull();
   }
-
-  private Account.Id id(int n) {
-    return new Account.Id(n);
-  }
 }
diff --git a/javatests/com/google/gerrit/entities/BUILD b/javatests/com/google/gerrit/entities/BUILD
new file mode 100644
index 0000000..b24781a
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "entities_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/entities/BranchTest.java b/javatests/com/google/gerrit/entities/BranchTest.java
new file mode 100644
index 0000000..0483ebc
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/BranchTest.java
@@ -0,0 +1,39 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class BranchTest {
+  @Test
+  public void canonicalizeNameDuringConstruction() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").branch())
+        .isEqualTo("refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "refs/heads/bar").branch())
+        .isEqualTo("refs/heads/bar");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").toString())
+        .isEqualTo("foo,refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo bar"), "bar baz").toString())
+        .isEqualTo("foo+bar,refs/heads/bar+baz");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo^bar"), "bar^baz").toString())
+        .isEqualTo("foo%5Ebar,refs/heads/bar%5Ebaz");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java b/javatests/com/google/gerrit/entities/ChangeTest.java
similarity index 86%
rename from javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
rename to javatests/com/google/gerrit/entities/ChangeTest.java
index 6d1d0a6..c75ad5a 100644
--- a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/javatests/com/google/gerrit/entities/ChangeTest.java
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class ChangeTest {
@@ -122,8 +123,8 @@
 
   @Test
   public void toRefPrefix() {
-    assertThat(new Change.Id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
-    assertThat(new Change.Id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
+    assertThat(Change.id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
+    assertThat(Change.id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
   }
 
   @Test
@@ -147,8 +148,20 @@
     assertNotRefPart("1/1");
   }
 
+  @Test
+  public void idToString() {
+    assertThat(Change.id(3).toString()).isEqualTo("3");
+  }
+
+  @Test
+  public void keyToString() {
+    String key = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    assertThat(ObjectId.isId(key.substring(1))).isTrue();
+    assertThat(Change.key(key).toString()).isEqualTo(key);
+  }
+
   private static void assertRef(int changeId, String refName) {
-    assertThat(Change.Id.fromRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotRef(String refName) {
@@ -156,7 +169,7 @@
   }
 
   private static void assertAllUsersRef(int changeId, String refName) {
-    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotAllUsersRef(String refName) {
@@ -164,7 +177,7 @@
   }
 
   private static void assertRefPart(int changeId, String refName) {
-    assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
+    assertEquals(Change.id(changeId), Change.Id.fromRefPart(refName));
   }
 
   private static void assertNotRefPart(String refName) {
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/javatests/com/google/gerrit/entities/PatchSetApprovalTest.java
similarity index 72%
rename from javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
rename to javatests/com/google/gerrit/entities/PatchSetApprovalTest.java
index 5e42ce0..81aa3b8 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ b/javatests/com/google/gerrit/entities/PatchSetApprovalTest.java
@@ -12,27 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.Test;
 
-public class PatchSetApprovalTest extends GerritBaseTests {
+public class PatchSetApprovalTest {
   @Test
   public void keyEquality() {
     PatchSetApproval.Key k1 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("My-Label"));
     PatchSetApproval.Key k2 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("My-Label"));
     PatchSetApproval.Key k3 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("Other-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("Other-Label"));
 
     assertThat(k2).isEqualTo(k1);
     assertThat(k3).isNotEqualTo(k1);
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java b/javatests/com/google/gerrit/entities/PatchSetTest.java
similarity index 64%
rename from javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
rename to javatests/com/google/gerrit/entities/PatchSetTest.java
index 51a405f..61e1b4c 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/javatests/com/google/gerrit/entities/PatchSetTest.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.PatchSet.joinGroups;
-import static com.google.gerrit.reviewdb.client.PatchSet.splitGroups;
+import static com.google.gerrit.entities.PatchSet.joinGroups;
+import static com.google.gerrit.entities.PatchSet.splitGroups;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import org.junit.Test;
@@ -64,37 +65,67 @@
 
   @Test
   public void testSplitGroups() {
+    assertRuntimeException(() -> splitGroups(null));
     assertThat(splitGroups("")).containsExactly("");
     assertThat(splitGroups("abcd")).containsExactly("abcd");
     assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
+    assertThat(splitGroups("ab , cd")).containsExactly("ab ", " cd").inOrder();
     assertThat(splitGroups("ab,")).containsExactly("ab", "").inOrder();
     assertThat(splitGroups(",cd")).containsExactly("", "cd").inOrder();
   }
 
   @Test
   public void testJoinGroups() {
+    assertRuntimeException(() -> joinGroups(null));
+    assertRuntimeException(() -> joinGroups(ImmutableList.of("a,", "b")));
     assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
     assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
     assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
+    assertThat(joinGroups(ImmutableList.of("ab ", " cd"))).isEqualTo("ab , cd");
     assertThat(joinGroups(ImmutableList.of("ab", ""))).isEqualTo("ab,");
     assertThat(joinGroups(ImmutableList.of("", "cd"))).isEqualTo(",cd");
   }
 
   @Test
   public void toRefName() {
-    assertThat(new PatchSet.Id(new Change.Id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
-    assertThat(new PatchSet.Id(new Change.Id(1234), 5).toRefName())
-        .isEqualTo("refs/changes/34/1234/5");
+    assertThat(PatchSet.id(Change.id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
+    assertThat(PatchSet.id(Change.id(1234), 5).toRefName()).isEqualTo("refs/changes/34/1234/5");
+  }
+
+  @Test
+  public void parseId() {
+    assertThat(PatchSet.Id.parse("1,2")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertThat(PatchSet.Id.parse("01,02")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertInvalidId(null);
+    assertInvalidId("");
+    assertInvalidId("1");
+    assertInvalidId("1,foo.txt");
+    assertInvalidId("foo.txt,1");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidId("1" + hexComma + "2");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(PatchSet.id(Change.id(2), 3).toString()).isEqualTo("2,3");
   }
 
   private static void assertRef(int changeId, int psId, String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isTrue();
-    assertThat(PatchSet.Id.fromRef(refName))
-        .isEqualTo(new PatchSet.Id(new Change.Id(changeId), psId));
+    assertThat(PatchSet.Id.fromRef(refName)).isEqualTo(PatchSet.id(Change.id(changeId), psId));
   }
 
   private static void assertNotRef(String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isFalse();
     assertThat(PatchSet.Id.fromRef(refName)).isNull();
   }
+
+  private static void assertInvalidId(String str) {
+    assertRuntimeException(() -> PatchSet.Id.parse(str));
+  }
+
+  private static void assertRuntimeException(Runnable runnable) {
+    assertThrows(RuntimeException.class, () -> runnable.run());
+  }
 }
diff --git a/javatests/com/google/gerrit/entities/PatchTest.java b/javatests/com/google/gerrit/entities/PatchTest.java
new file mode 100644
index 0000000..9f906a9
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PatchTest.java
@@ -0,0 +1,54 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+
+public class PatchTest {
+  @Test
+  public void isMagic() {
+    assertThat(Patch.isMagic("/COMMIT_MSG")).isTrue();
+    assertThat(Patch.isMagic("/MERGE_LIST")).isTrue();
+
+    assertThat(Patch.isMagic("/COMMIT_MSG/")).isFalse();
+    assertThat(Patch.isMagic("COMMIT_MSG")).isFalse();
+    assertThat(Patch.isMagic("/commit_msg")).isFalse();
+  }
+
+  @Test
+  public void parseKey() {
+    assertThat(Patch.Key.parse("1,2,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertThat(Patch.Key.parse("01,02,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertInvalidKey(null);
+    assertInvalidKey("");
+    assertInvalidKey("1,2");
+    assertInvalidKey("1, 2, foo.txt");
+    assertInvalidKey("1,foo.txt");
+    assertInvalidKey("1,foo.txt,2");
+    assertInvalidKey("foo.txt,1,2");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidKey("1" + hexComma + "2" + hexComma + "foo.txt");
+  }
+
+  private static void assertInvalidKey(String str) {
+    assertThrows(RuntimeException.class, () -> Patch.Key.parse(str));
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/ProjectTest.java b/javatests/com/google/gerrit/entities/ProjectTest.java
new file mode 100644
index 0000000..341b54f
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/ProjectTest.java
@@ -0,0 +1,39 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ProjectTest {
+  @Test
+  public void parseId() {
+    assertThat(Project.NameKey.parse("foo")).isEqualTo(new Project.NameKey("foo"));
+    assertThat(Project.NameKey.parse("foo%20bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo+bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo%2fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+    assertThat(Project.NameKey.parse("foo%2Fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(Project.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(Project.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(Project.nameKey("foo/bar").toString()).isEqualTo("foo/bar");
+    assertThat(Project.nameKey("foo^bar").toString()).isEqualTo("foo%5Ebar");
+    assertThat(Project.nameKey("foo%bar").toString()).isEqualTo("foo%25bar");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java b/javatests/com/google/gerrit/entities/RefNamesTest.java
similarity index 89%
rename from javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
rename to javatests/com/google/gerrit/entities/RefNamesTest.java
index fa6a722..1990292 100644
--- a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/javatests/com/google/gerrit/entities/RefNamesTest.java
@@ -12,29 +12,25 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.entities;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.parseAfterShardedRefPart;
-import static com.google.gerrit.reviewdb.client.RefNames.parseRefSuffix;
-import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
-import static com.google.gerrit.reviewdb.client.RefNames.parseShardedUuidFromRefPart;
-import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
+import static com.google.gerrit.entities.RefNames.parseAfterShardedRefPart;
+import static com.google.gerrit.entities.RefNames.parseRefSuffix;
+import static com.google.gerrit.entities.RefNames.parseShardedRefPart;
+import static com.google.gerrit.entities.RefNames.parseShardedUuidFromRefPart;
+import static com.google.gerrit.entities.RefNames.skipShardedRefPart;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class RefNamesTest {
   private static final String TEST_GROUP_UUID = "ccab3195282a8ce4f5014efa391e82d10f884c64";
   private static final String TEST_SHARDED_GROUP_UUID =
       TEST_GROUP_UUID.substring(0, 2) + "/" + TEST_GROUP_UUID;
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
-  private final Account.Id accountId = new Account.Id(1011123);
-  private final Change.Id changeId = new Change.Id(67473);
-  private final PatchSet.Id psId = new PatchSet.Id(changeId, 42);
+  private final Account.Id accountId = Account.id(1011123);
+  private final Change.Id changeId = Change.id(67473);
+  private final PatchSet.Id psId = PatchSet.id(changeId, 42);
 
   @Test
   public void fullName() throws Exception {
@@ -54,34 +50,35 @@
     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
   public void refForGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsGroups(groupUuid));
   }
 
   @Test
   public void refForDeletedGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsDeletedGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/deleted-groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForDeletedGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsDeletedGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsDeletedGroups(groupUuid));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
similarity index 86%
rename from javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
index 123a973..0e4fbc8 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.protobuf.Parser;
 import org.junit.Test;
 
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Account.Id accountId = new Account.Id(24);
+    Account.Id accountId = Account.id(24);
 
     Entities.Account_Id proto = accountIdProtoConverter.toProto(accountId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Account.Id accountId = new Account.Id(34832);
+    Account.Id accountId = Account.id(34832);
 
     Account.Id convertedAccountId =
         accountIdProtoConverter.fromProto(accountIdProtoConverter.toProto(accountId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Account.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Account.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
similarity index 79%
rename from javatests/com/google/gerrit/reviewdb/converter/BUILD
rename to javatests/com/google/gerrit/entities/converter/BUILD
index 9cc941c..6ca9871 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -4,10 +4,12 @@
     name = "proto_converter_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/proto/testing",
-        "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:protobuf",
+        "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:entities_java_proto",
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
similarity index 73%
rename from javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
index 412641f..0a73db8 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
@@ -33,23 +33,23 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Branch.NameKey nameKey = new Branch.NameKey(new Project.NameKey("project-13"), "branch-72");
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-13"), "branch-72");
 
     Entities.Branch_NameKey proto = branchNameKeyProtoConverter.toProto(nameKey);
 
     Entities.Branch_NameKey expectedProto =
         Entities.Branch_NameKey.newBuilder()
-            .setProjectName(Entities.Project_NameKey.newBuilder().setName("project-13"))
-            .setBranchName("refs/heads/branch-72")
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project-13"))
+            .setBranch("refs/heads/branch-72")
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Branch.NameKey nameKey = new Branch.NameKey(new Project.NameKey("project-52"), "branch 14");
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-52"), "branch 14");
 
-    Branch.NameKey convertedNameKey =
+    BranchNameKey convertedNameKey =
         branchNameKeyProtoConverter.fromProto(branchNameKeyProtoConverter.toProto(nameKey));
 
     assertThat(convertedNameKey).isEqualTo(nameKey);
@@ -59,8 +59,8 @@
   public void protoCanBeParsedFromBytes() throws Exception {
     Entities.Branch_NameKey proto =
         Entities.Branch_NameKey.newBuilder()
-            .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranchName("branch 36")
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
+            .setBranch("branch 36")
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -72,12 +72,12 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Branch.NameKey.class)
-        .hasFields(
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(BranchNameKey.class)
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
-                .put("projectName", Project.NameKey.class)
-                .put("branchName", String.class)
+                .put("project", Project.NameKey.class)
+                .put("branch", String.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
similarity index 86%
rename from javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
index d5ebb51..12f3f33 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 import org.junit.Test;
 
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Change.Id changeId = new Change.Id(94);
+    Change.Id changeId = Change.id(94);
 
     Entities.Change_Id proto = changeIdProtoConverter.toProto(changeId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Change.Id changeId = new Change.Id(2903482);
+    Change.Id changeId = Change.id(2903482);
 
     Change.Id convertedChangeId =
         changeIdProtoConverter.fromProto(changeIdProtoConverter.toProto(changeId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Change.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
similarity index 86%
rename from javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
index d948706..e9080b3 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 import org.junit.Test;
 
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Change.Key changeKey = new Change.Key("change-1");
+    Change.Key changeKey = Change.key("change-1");
 
     Entities.Change_Key proto = changeKeyProtoConverter.toProto(changeKey);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Change.Key changeKey = new Change.Key("change-52");
+    Change.Key changeKey = Change.key("change-52");
 
     Change.Key convertedChangeKey =
         changeKeyProtoConverter.fromProto(changeKeyProtoConverter.toProto(changeKey));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Change.Key.class).hasFields(ImmutableMap.of("id", String.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Key.class)
+        .hasAutoValueMethods(ImmutableMap.of("key", String.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
similarity index 87%
rename from javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
index c8bb2ed..72ce896 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
@@ -33,7 +33,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
 
     Entities.ChangeMessage_Key proto = messageKeyProtoConverter.toProto(messageKey);
 
@@ -47,7 +47,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
 
     ChangeMessage.Key convertedMessageKey =
         messageKeyProtoConverter.fromProto(messageKeyProtoConverter.toProto(messageKey));
@@ -72,9 +72,9 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(ChangeMessage.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("uuid", String.class)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
similarity index 82%
rename from javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 65bdfbb..933ffb4 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -12,19 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
@@ -38,13 +38,13 @@
   public void allValuesConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
     changeMessage.setMessage("This is a change message.");
     changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(new Account.Id(10003));
+    changeMessage.setRealAuthor(Account.id(10003));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -60,7 +60,7 @@
             .setPatchset(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(34))
-                    .setPatchSetId(13))
+                    .setId(13))
             .setTag("An arbitrary tag.")
             .setRealAuthor(Entities.Account_Id.newBuilder().setId(10003))
             .build();
@@ -71,10 +71,10 @@
   public void mainValuesConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -89,7 +89,7 @@
             .setPatchset(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(34))
-                    .setPatchSetId(13))
+                    .setId(13))
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -99,10 +99,7 @@
   public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
-            null,
-            null);
+            ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -122,8 +119,7 @@
     // writtenOn may not be null according to the column definition but it's optional for the
     // protobuf definition. -> assume as optional and hence test null
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -141,13 +137,13 @@
   public void allValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
     changeMessage.setMessage("This is a change message.");
     changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(new Account.Id(10003));
+    changeMessage.setRealAuthor(Account.id(10003));
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -158,10 +154,10 @@
   public void mainValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -171,8 +167,7 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
similarity index 81%
rename from javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 61bf105..72e4a7a 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -12,20 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
@@ -38,22 +38,22 @@
   public void allValuesConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch 74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
             new Timestamp(987654L));
     change.setLastUpdatedOn(new Timestamp(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
-        new PatchSet.Id(new Change.Id(14), 23), "subject XYZ", "original subject ABC");
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(new Account.Id(100001));
+    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
-    change.setRevertOf(new Change.Id(180));
+    change.setRevertOf(Change.id(180));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -67,8 +67,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch 74"))
             .setStatus(Change.STATUS_MERGED)
             .setCurrentPatchSetId(23)
             .setSubject("subject XYZ")
@@ -88,10 +88,10 @@
   public void mandatoryValuesConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -106,8 +106,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             // Default values which can't be unset.
             .setCurrentPatchSetId(0)
             .setRowVersion(0)
@@ -124,13 +124,13 @@
   public void currentPatchSetIsAlwaysSetWhenConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
     // O as ID actually means that no current patch set is present.
-    change.setCurrentPatchSet(new PatchSet.Id(new Change.Id(14), 0), null, null);
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -144,8 +144,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(0)
             // Default values which can't be unset.
             .setRowVersion(0)
@@ -162,12 +162,12 @@
   public void originalSubjectIsNotAutomaticallySetToSubjectWhenConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
-    change.setCurrentPatchSet(new PatchSet.Id(new Change.Id(14), 23), "subject ABC", null);
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -181,8 +181,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(23)
             .setSubject("subject ABC")
             // Default values which can't be unset.
@@ -199,22 +199,22 @@
   public void allValuesConvertedToProtoAndBackAgain() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
     change.setLastUpdatedOn(new Timestamp(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
-        new PatchSet.Id(new Change.Id(14), 23), "subject XYZ", "original subject ABC");
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(new Account.Id(100001));
+    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
-    change.setRevertOf(new Change.Id(180));
+    change.setRevertOf(Change.id(180));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
     assertEqualChange(convertedChange, change);
@@ -224,10 +224,10 @@
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
@@ -269,8 +269,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
             .build();
     Change change = changeProtoConverter.fromProto(proto);
 
@@ -289,8 +289,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
             .setStatus(Change.STATUS_MERGED)
             .setCurrentPatchSetId(23)
             .setSubject("subject XYZ")
@@ -323,7 +323,7 @@
                 .put("createdOn", Timestamp.class)
                 .put("lastUpdatedOn", Timestamp.class)
                 .put("owner", Account.Id.class)
-                .put("dest", Branch.NameKey.class)
+                .put("dest", BranchNameKey.class)
                 .put("status", char.class)
                 .put("currentPatchSetId", int.class)
                 .put("subject", String.class)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
similarity index 86%
rename from javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
index 41e0f3f..88b9fb6 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.protobuf.Parser;
 import org.junit.Test;
 
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    LabelId labelId = new LabelId("Label ID 42");
+    LabelId labelId = LabelId.create("Label ID 42");
 
     Entities.LabelId proto = labelIdProtoConverter.toProto(labelId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    LabelId labelId = new LabelId("label-5");
+    LabelId labelId = LabelId.create("label-5");
 
     LabelId convertedLabelId =
         labelIdProtoConverter.fromProto(labelIdProtoConverter.toProto(labelId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(LabelId.class).hasFields(ImmutableMap.of("id", String.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(LabelId.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", String.class));
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
new file mode 100644
index 0000000..8408b69
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
@@ -0,0 +1,75 @@
+// 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.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdProtoConverterTest {
+  private final ObjectIdProtoConverter objectIdProtoConverter = ObjectIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    Entities.ObjectId proto = objectIdProtoConverter.toProto(objectId);
+
+    Entities.ObjectId expectedProto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    ObjectId convertedObjectId =
+        objectIdProtoConverter.fromProto(objectIdProtoConverter.toProto(objectId));
+
+    assertThat(convertedObjectId).isEqualTo(objectId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.ObjectId proto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
+    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ObjectId.class)
+        .hasFields(
+            ImmutableMap.of(
+                "w1", int.class,
+                "w2", int.class,
+                "w3", int.class,
+                "w4", int.class,
+                "w5", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
similarity index 76%
rename from javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
index d1ed419..11aac0d 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -12,20 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
@@ -37,8 +37,8 @@
   @Test
   public void allValuesConvertedToProto() {
     PatchSetApproval.Key key =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
 
     Entities.PatchSetApproval_Key proto = protoConverter.toProto(key);
 
@@ -47,9 +47,9 @@
             .setPatchSetId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setPatchSetId(14))
+                    .setId(14))
             .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setCategoryId(Entities.LabelId.newBuilder().setId("label-8"))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -57,8 +57,8 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval.Key key =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
 
     PatchSetApproval.Key convertedKey = protoConverter.fromProto(protoConverter.toProto(key));
 
@@ -72,9 +72,9 @@
             .setPatchSetId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setPatchSetId(14))
+                    .setId(14))
             .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setCategoryId(Entities.LabelId.newBuilder().setId("label-8"))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -86,13 +86,13 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(PatchSetApproval.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("patchSetId", PatchSet.Id.class)
                 .put("accountId", Account.Id.class)
-                .put("categoryId", LabelId.class)
+                .put("labelId", LabelId.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
similarity index 68%
rename from javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index 80b2cc2..bca5eea 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -12,24 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.inject.TypeLiteral;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
+import java.util.Optional;
 import org.junit.Test;
 
 public class PatchSetApprovalProtoConverterTest {
@@ -39,16 +41,16 @@
   @Test
   public void allValuesConvertedToProto() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
-    patchSetApproval.setTag("tag-21");
-    patchSetApproval.setRealAccountId(new Account.Id(612));
-    patchSetApproval.setPostSubmit(true);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
 
@@ -59,9 +61,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             .setTag("tag-21")
@@ -74,13 +76,13 @@
   @Test
   public void mandatoryValuesConvertedToProto() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
 
@@ -91,9 +93,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             // This value can't be unset when our entity class is given.
@@ -105,16 +107,16 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
-    patchSetApproval.setTag("tag-21");
-    patchSetApproval.setRealAccountId(new Account.Id(612));
-    patchSetApproval.setPostSubmit(true);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
 
     PatchSetApproval convertedPatchSetApproval =
         protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
@@ -124,13 +126,13 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
 
     PatchSetApproval convertedPatchSetApproval =
         protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
@@ -148,19 +150,19 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .build();
     PatchSetApproval patchSetApproval = protoConverter.fromProto(proto);
 
-    assertThat(patchSetApproval.getPatchSetId()).isEqualTo(new PatchSet.Id(new Change.Id(42), 14));
-    assertThat(patchSetApproval.getAccountId()).isEqualTo(new Account.Id(100013));
-    assertThat(patchSetApproval.getLabelId()).isEqualTo(new LabelId("label-8"));
+    assertThat(patchSetApproval.patchSetId()).isEqualTo(PatchSet.id(Change.id(42), 14));
+    assertThat(patchSetApproval.accountId()).isEqualTo(Account.id(100013));
+    assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
     // Default values for unset protobuf fields which can't be unset in the entity object.
-    assertThat(patchSetApproval.getValue()).isEqualTo(0);
-    assertThat(patchSetApproval.getGranted()).isEqualTo(new Timestamp(0));
-    assertThat(patchSetApproval.isPostSubmit()).isEqualTo(false);
+    assertThat(patchSetApproval.value()).isEqualTo(0);
+    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
   @Test
@@ -172,9 +174,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             .build();
@@ -190,14 +192,15 @@
   @Test
   public void fieldsExistAsExpected() {
     assertThatSerializedClass(PatchSetApproval.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
                 .put("value", short.class)
                 .put("granted", Timestamp.class)
-                .put("tag", String.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
similarity index 85%
rename from javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
index 1598ef2..530b431 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
@@ -33,21 +33,21 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(103), 73);
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(103), 73);
 
     Entities.PatchSet_Id proto = patchSetIdProtoConverter.toProto(patchSetId);
 
     Entities.PatchSet_Id expectedProto =
         Entities.PatchSet_Id.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setPatchSetId(73)
+            .setId(73)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(20), 13);
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(20), 13);
 
     PatchSet.Id convertedPatchSetId =
         patchSetIdProtoConverter.fromProto(patchSetIdProtoConverter.toProto(patchSetId));
@@ -60,7 +60,7 @@
     Entities.PatchSet_Id proto =
         Entities.PatchSet_Id.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setPatchSetId(73)
+            .setId(73)
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -72,12 +72,12 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(PatchSet.Id.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
-                .put("patchSetId", int.class)
+                .put("id", int.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
new file mode 100644
index 0000000..2519e75
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Truth;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class PatchSetProtoConverterTest {
+  private final PatchSetProtoConverter patchSetProtoConverter = PatchSetProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
+
+    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Entities.PatchSet expectedProto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
+            .setGroups("group1, group2")
+            .setPushCertificate("my push certificate")
+            .setDescription("This is a patch set description.")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
+
+    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Entities.PatchSet expectedProto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void previouslyOptionalValuesMayBeMissingFromProto() {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .build();
+
+    PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+    Truth.assertThat(convertedPatchSet)
+        .isEqualTo(
+            PatchSet.builder()
+                .id(PatchSet.id(Change.id(103), 73))
+                .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+                .uploader(Account.id(0))
+                .createdOn(new Timestamp(0))
+                .build());
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
+    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSet.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("id", PatchSet.Id.class)
+                .put("commitId", ObjectId.class)
+                .put("uploader", Account.Id.class)
+                .put("createdOn", Timestamp.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
similarity index 91%
rename from javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
rename to javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
index 2ad6107..2f693e6 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.converter;
+package com.google.gerrit.entities.converter;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 import org.junit.Test;
 
@@ -31,7 +31,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Project.NameKey nameKey = new Project.NameKey("project-72");
+    Project.NameKey nameKey = Project.nameKey("project-72");
 
     Entities.Project_NameKey proto = projectNameKeyProtoConverter.toProto(nameKey);
 
@@ -42,7 +42,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Project.NameKey nameKey = new Project.NameKey("project-52");
+    Project.NameKey nameKey = Project.nameKey("project-52");
 
     Project.NameKey convertedNameKey =
         projectNameKeyProtoConverter.fromProto(projectNameKeyProtoConverter.toProto(nameKey));
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 94e433c..2202a11 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -7,7 +7,6 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/guice",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java b/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
index 86dce04..0be10ee 100644
--- a/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
+++ b/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.junit.Test;
 
-public class LfsDefinitionsTest extends GerritBaseTests {
+public class LfsDefinitionsTest {
   private static final String[] URL_PREFIXES = new String[] {"/", "/a/", "/p/", "/a/p/"};
 
   @Test
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
index 4bb9107..5e8c7b6 100644
--- a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -21,11 +21,10 @@
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
 
 import com.google.common.math.IntMath;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.EnumSet;
 import org.junit.Test;
 
-public class ListOptionTest extends GerritBaseTests {
+public class ListOptionTest {
   enum MyOption implements ListOption {
     FOO(0),
     BAR(1),
diff --git a/javatests/com/google/gerrit/extensions/client/RangeTest.java b/javatests/com/google/gerrit/extensions/client/RangeTest.java
index 2c713b5..b8938aa 100644
--- a/javatests/com/google/gerrit/extensions/client/RangeTest.java
+++ b/javatests/com/google/gerrit/extensions/client/RangeTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.gerrit.extensions.common.testing.RangeSubject.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RangeTest extends GerritBaseTests {
+public class RangeTest {
 
   @Test
   public void rangeOverMultipleLinesWithSmallerEndCharacterIsValid() {
diff --git a/javatests/com/google/gerrit/extensions/conditions/BUILD b/javatests/com/google/gerrit/extensions/conditions/BUILD
index 7ad2ad3..e2d5951 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BUILD
+++ b/javatests/com/google/gerrit/extensions/conditions/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/extensions:lib",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
index 81cb719..f9f1fa85 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
+++ b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
@@ -20,10 +20,9 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.valueOf;
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class BooleanConditionTest extends GerritBaseTests {
+public class BooleanConditionTest {
 
   private static final BooleanCondition NO_TRIVIAL_EVALUATION =
       new BooleanCondition() {
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index d950224..0542c35 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -17,14 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 import java.util.Iterator;
 import org.junit.Test;
 
-public class DynamicSetTest extends GerritBaseTests {
+public class DynamicSetTest {
   // In tests for {@link DynamicSet#contains(Object)}, be sure to avoid
   // {@code assertThat(ds).contains(...) @} and
   // {@code assertThat(ds).DoesNotContains(...) @} as (since
diff --git a/javatests/com/google/gerrit/git/BUILD b/javatests/com/google/gerrit/git/BUILD
index d57d73f..c2c9cce 100644
--- a/javatests/com/google/gerrit/git/BUILD
+++ b/javatests/com/google/gerrit/git/BUILD
@@ -10,11 +10,10 @@
     tags = ["no_windows"],
     deps = [
         "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:junit",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
     ],
 )
@@ -30,7 +29,8 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/git/ObjectIdsTest.java b/javatests/com/google/gerrit/git/ObjectIdsTest.java
new file mode 100644
index 0000000..b254d6f
--- /dev/null
+++ b/javatests/com/google/gerrit/git/ObjectIdsTest.java
@@ -0,0 +1,156 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
+
+import java.util.function.Function;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.junit.Test;
+
+public class ObjectIdsTest {
+  private static final ObjectId ID =
+      ObjectId.fromString("0000000000100000000000000000000000000000");
+  private static final ObjectId AMBIGUOUS_BLOB_ID =
+      ObjectId.fromString("0000000000b36b6aa7ea4b75318ed078f55505c3");
+  private static final ObjectId AMBIGUOUS_TREE_ID =
+      ObjectId.fromString("0000000000cdcf04beb2fab69e65622616294984");
+
+  @Test
+  public void abbreviateNameDefaultLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null));
+    assertThat(abbreviateName(ID)).isEqualTo("0000000");
+    assertThat(abbreviateName(AMBIGUOUS_BLOB_ID)).isEqualTo(abbreviateName(ID));
+    assertThat(abbreviateName(AMBIGUOUS_TREE_ID)).isEqualTo(abbreviateName(ID));
+  }
+
+  @Test
+  public void abbreviateNameCustomLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null, 1));
+    assertRuntimeException(() -> abbreviateName(ID, -1));
+    assertRuntimeException(() -> abbreviateName(ID, 0));
+    assertRuntimeException(() -> abbreviateName(ID, 41));
+    assertThat(abbreviateName(ID, 5)).isEqualTo("00000");
+    assertThat(abbreviateName(ID, 40)).isEqualTo(ID.name());
+  }
+
+  @Test
+  public void abbreviateNameDefaultLengthWithReader() throws Exception {
+    assertRuntimeException(() -> abbreviateName(ID, null));
+
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertThat(abbreviateName(ID, reader)).isEqualTo("00000000001");
+  }
+
+  @Test
+  public void abbreviateNameCustomLengthWithReader() throws Exception {
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertRuntimeException(() -> abbreviateName(ID, -1, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 0, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 41, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 5, null));
+
+    String shortest = "00000000001";
+    assertThat(abbreviateName(ID, 1, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, 7, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length(), reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length() + 1, reader)).isEqualTo("000000000010");
+  }
+
+  @Test
+  public void copyOrNull() throws Exception {
+    testCopy(ObjectIds::copyOrNull);
+    assertThat(ObjectIds.copyOrNull(null)).isNull();
+  }
+
+  @Test
+  public void copyOrZero() throws Exception {
+    testCopy(ObjectIds::copyOrZero);
+    assertThat(ObjectIds.copyOrZero(null)).isEqualTo(ObjectId.zeroId());
+  }
+
+  private void testCopy(Function<AnyObjectId, ObjectId> copyFunc) {
+    MyObjectId myId = new MyObjectId(ID);
+    assertThat(myId).isEqualTo(ID);
+
+    ObjectId copy = copyFunc.apply(myId);
+    assertThat(copy).isEqualTo(myId);
+    assertThat(copy).isNotSameInstanceAs(myId);
+    assertThat(copy.getClass()).isEqualTo(ObjectId.class);
+  }
+
+  @Test
+  public void matchesAbbreviation() throws Exception {
+    assertThat(ObjectIds.matchesAbbreviation(null, "")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "0")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "00000")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, ID.name())).isFalse();
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "")).isTrue();
+    for (int i = 1; i <= OBJECT_ID_STRING_LENGTH; i++) {
+      String prefix = ID.name().substring(0, i);
+      assertWithMessage("match %s against %s", ID.name(), prefix)
+          .that(ObjectIds.matchesAbbreviation(ID, prefix))
+          .isTrue();
+    }
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "x")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, AMBIGUOUS_BLOB_ID.name())).isFalse();
+  }
+
+  @FunctionalInterface
+  private interface Func {
+    void call() throws Exception;
+  }
+
+  private static void assertRuntimeException(Func func) throws Exception {
+    assertThrows(RuntimeException.class, () -> func.call());
+  }
+
+  private static ObjectReader newReaderWithAmbiguousIds() throws Exception {
+    // Recipe for creating ambiguous IDs courtesy of git core:
+    // https://github.com/git/git/blob/df799f5d99ac51d4fc791d546de3f936088582fc/t/t1512-rev-parse-disambiguation.sh
+    try (TestRepository<Repository> tr =
+        new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")))) {
+      String blobData = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n\nb1rwzyc3\n";
+      RevBlob blob = tr.blob(blobData);
+      assertThat(blob.name()).isEqualTo(AMBIGUOUS_BLOB_ID.name());
+      assertThat(tr.tree(tr.file("a0blgqsjc", blob)).name()).isEqualTo(AMBIGUOUS_TREE_ID.name());
+      return tr.getRevWalk().getObjectReader();
+    }
+  }
+
+  private static class MyObjectId extends ObjectId {
+    private static final long serialVersionUID = 1L;
+
+    MyObjectId(AnyObjectId src) {
+      super(src);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
index 59961ff..60b90f3 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.MoreFiles;
 import com.google.common.io.RecursiveDeleteOption;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -36,7 +35,7 @@
 import org.junit.runners.Parameterized.Parameters;
 
 @RunWith(Parameterized.class)
-public class RefUpdateUtilRepoTest extends GerritBaseTests {
+public class RefUpdateUtilRepoTest {
   public enum RepoSetup {
     LOCAL_DISK {
       @Override
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
index 429583a..1d021f7 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.function.Consumer;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -33,7 +32,7 @@
 import org.junit.runners.JUnit4;
 
 @RunWith(JUnit4.class)
-public class RefUpdateUtilTest extends GerritBaseTests {
+public class RefUpdateUtilTest {
   private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
   private static final Consumer<ReceiveCommand> LOCK_FAILURE =
       c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
@@ -81,23 +80,18 @@
 
   @SafeVarargs
   private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected IOException");
-    } catch (IOException e) {
-      assertThat(e).isNotInstanceOf(LockFailureException.class);
-    }
+    IOException thrown =
+        assertThrows(
+            IOException.class, () -> RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters)));
+    assertThat(thrown).isNotInstanceOf(LockFailureException.class);
   }
 
   @SafeVarargs
   private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
       throws Exception {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Expected.
-    }
+    assertThrows(
+        LockFailureException.class,
+        () -> RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters)));
   }
 
   @SafeVarargs
diff --git a/javatests/com/google/gerrit/git/testing/BUILD b/javatests/com/google/gerrit/git/testing/BUILD
index 1309185..56e9ec2 100644
--- a/javatests/com/google/gerrit/git/testing/BUILD
+++ b/javatests/com/google/gerrit/git/testing/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/git/testing",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
index 5ab52d4..3bf815b 100644
--- a/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
+++ b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
@@ -18,10 +18,9 @@
 import static com.google.gerrit.git.testing.PushResultSubject.parseProcessed;
 import static com.google.gerrit.git.testing.PushResultSubject.trimMessages;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class PushResultSubjectTest extends GerritBaseTests {
+public class PushResultSubjectTest {
   @Test
   public void testTrimMessages() {
     assertThat(trimMessages(null)).isNull();
diff --git a/javatests/com/google/gerrit/gpg/BUILD b/javatests/com/google/gerrit/gpg/BUILD
index f73208d..f459b1a 100644
--- a/javatests/com/google/gerrit/gpg/BUILD
+++ b/javatests/com/google/gerrit/gpg/BUILD
@@ -5,16 +5,19 @@
     srcs = glob(["**/*.java"]),
     tags = ["no_windows"],
     visibility = ["//visibility:public"],
+    runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/gpg/testing:gpg-test-util",
         "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/bouncycastle:bcpg",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov",
@@ -22,8 +25,6 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 220361e..45b3419 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -29,10 +29,10 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterators;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountManager;
@@ -41,7 +41,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -64,7 +63,7 @@
 import org.junit.Test;
 
 /** Unit tests for {@link GerritPublicKeyChecker}. */
-public class GerritPublicKeyCheckerTest extends GerritBaseTests {
+public class GerritPublicKeyCheckerTest {
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
 
   @Inject private AccountManager accountManager;
@@ -199,7 +198,7 @@
         .update(
             "Delete External IDs",
             user.getAccountId(),
-            (a, u) -> u.deleteExternalIds(a.getExternalIds()));
+            (a, u) -> u.deleteExternalIds(a.externalIds()));
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 145b9cf..7703fb0 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,7 +38,6 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -60,7 +59,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PublicKeyCheckerTest extends GerritBaseTests {
+public class PublicKeyCheckerTest {
   private InMemoryRepository repo;
   private PublicKeyStore store;
 
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index be65752..3727d38 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -29,7 +29,6 @@
 
 import com.google.common.collect.Iterators;
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Iterator;
@@ -51,7 +50,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PublicKeyStoreTest extends GerritBaseTests {
+public class PublicKeyStoreTest {
   private TestRepository<?> tr;
   private PublicKeyStore store;
 
diff --git a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
index 67bf050..266f868 100644
--- a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -23,7 +23,6 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStreamReader;
@@ -54,7 +53,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PushCertificateCheckerTest extends GerritBaseTests {
+public class PushCertificateCheckerTest {
   private InMemoryRepository repo;
   private PublicKeyStore store;
   private SignedPushConfig signedPushConfig;
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index e2c58d8..4932248 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.httpd;
 
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.capture;
-import static org.easymock.EasyMock.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.server.plugins.Plugin;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import com.google.inject.Key;
@@ -30,13 +32,12 @@
 import javax.servlet.FilterConfig;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.easymock.Capture;
-import org.easymock.EasyMockSupport;
-import org.easymock.IMocksControl;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 
-public class AllRequestFilterFilterProxyTest extends GerritBaseTests {
+public class AllRequestFilterFilterProxyTest {
   /**
    * Set of filters for FilterProxy
    *
@@ -82,16 +83,11 @@
 
   @Test
   public void noFilters() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req = new FakeHttpServletRequest();
     HttpServletResponse res = new FakeHttpServletResponse();
 
-    FilterChain chain = ems.createMock(FilterChain.class);
-    chain.doFilter(req, res);
-
-    ems.replayAll();
+    FilterChain chain = mock(FilterChain.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
 
@@ -99,25 +95,18 @@
     filterProxy.doFilter(req, res, chain);
     filterProxy.destroy();
 
-    ems.verifyAll();
+    verify(chain).doFilter(req, res);
   }
 
   @Test
   public void singleFilterNoBubbling() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock("config", FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req = new FakeHttpServletRequest();
     HttpServletResponse res = new FakeHttpServletResponse();
 
-    FilterChain chain = ems.createMock("chain", FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    AllRequestFilter filter = ems.createStrictMock("filter", AllRequestFilter.class);
-    filter.init(config);
-    filter.doFilter(eq(req), eq(res), anyObject(FilterChain.class));
-    filter.destroy();
-
-    ems.replayAll();
+    AllRequestFilter filter = mock(AllRequestFilter.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     addFilter(filter);
@@ -126,63 +115,52 @@
     filterProxy.doFilter(req, res, chain);
     filterProxy.destroy();
 
-    ems.verifyAll();
+    InOrder inorder = inOrder(filter);
+    inorder.verify(filter).init(config);
+    inorder.verify(filter).doFilter(eq(req), eq(res), any(FilterChain.class));
+    inorder.verify(filter).destroy();
   }
 
   @Test
   public void singleFilterBubbling() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req = new FakeHttpServletRequest();
     HttpServletResponse res = new FakeHttpServletResponse();
 
-    IMocksControl mockControl = ems.createStrictControl();
-    FilterChain chain = mockControl.createMock(FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    Capture<FilterChain> capturedChain = new Capture<>();
+    ArgumentCaptor<FilterChain> capturedChain = ArgumentCaptor.forClass(FilterChain.class);
 
-    AllRequestFilter filter = mockControl.createMock(AllRequestFilter.class);
-    filter.init(config);
-    filter.doFilter(eq(req), eq(res), capture(capturedChain));
-    chain.doFilter(req, res);
-    filter.destroy();
-
-    ems.replayAll();
+    AllRequestFilter filter = mock(AllRequestFilter.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     addFilter(filter);
 
+    InOrder inorder = inOrder(filter, chain);
+
     filterProxy.init(config);
     filterProxy.doFilter(req, res, chain);
-    capturedChain.getValue().doFilter(req, res);
-    filterProxy.destroy();
 
-    ems.verifyAll();
+    inorder.verify(filter).init(config);
+    inorder.verify(filter).doFilter(eq(req), eq(res), capturedChain.capture());
+    capturedChain.getValue().doFilter(req, res);
+    inorder.verify(chain).doFilter(req, res);
+
+    filterProxy.destroy();
+    inorder.verify(filter).destroy();
   }
 
   @Test
   public void twoFiltersNoBubbling() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req = new FakeHttpServletRequest();
     HttpServletResponse res = new FakeHttpServletResponse();
 
-    IMocksControl mockControl = ems.createStrictControl();
-    FilterChain chain = mockControl.createMock(FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    AllRequestFilter filterA = mockControl.createMock(AllRequestFilter.class);
+    AllRequestFilter filterA = mock(AllRequestFilter.class);
 
-    AllRequestFilter filterB = mockControl.createMock(AllRequestFilter.class);
-    filterA.init(config);
-    filterB.init(config);
-    filterA.doFilter(eq(req), eq(res), anyObject(FilterChain.class));
-    filterA.destroy();
-    filterB.destroy();
-
-    ems.replayAll();
-
+    AllRequestFilter filterB = mock(AllRequestFilter.class);
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     addFilter(filterA);
     addFilter(filterB);
@@ -191,35 +169,27 @@
     filterProxy.doFilter(req, res, chain);
     filterProxy.destroy();
 
-    ems.verifyAll();
+    InOrder inorder = inOrder(filterA, filterB);
+    inorder.verify(filterA).init(config);
+    inorder.verify(filterB).init(config);
+    inorder.verify(filterA).doFilter(eq(req), eq(res), any(FilterChain.class));
+    inorder.verify(filterA).destroy();
+    inorder.verify(filterB).destroy();
   }
 
   @Test
   public void twoFiltersBubbling() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req = new FakeHttpServletRequest();
     HttpServletResponse res = new FakeHttpServletResponse();
 
-    IMocksControl mockControl = ems.createStrictControl();
-    FilterChain chain = mockControl.createMock(FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    Capture<FilterChain> capturedChainA = new Capture<>();
-    Capture<FilterChain> capturedChainB = new Capture<>();
+    ArgumentCaptor<FilterChain> capturedChainA = ArgumentCaptor.forClass(FilterChain.class);
+    ArgumentCaptor<FilterChain> capturedChainB = ArgumentCaptor.forClass(FilterChain.class);
 
-    AllRequestFilter filterA = mockControl.createMock(AllRequestFilter.class);
-    AllRequestFilter filterB = mockControl.createMock(AllRequestFilter.class);
-
-    filterA.init(config);
-    filterB.init(config);
-    filterA.doFilter(eq(req), eq(res), capture(capturedChainA));
-    filterB.doFilter(eq(req), eq(res), capture(capturedChainB));
-    chain.doFilter(req, res);
-    filterA.destroy();
-    filterB.destroy();
-
-    ems.replayAll();
+    AllRequestFilter filterA = mock(AllRequestFilter.class);
+    AllRequestFilter filterB = mock(AllRequestFilter.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     addFilter(filterA);
@@ -227,70 +197,69 @@
 
     filterProxy.init(config);
     filterProxy.doFilter(req, res, chain);
-    capturedChainA.getValue().doFilter(req, res);
-    capturedChainB.getValue().doFilter(req, res);
-    filterProxy.destroy();
 
-    ems.verifyAll();
+    InOrder inorder = inOrder(filterA, filterB, chain);
+
+    inorder.verify(filterA).init(config);
+    inorder.verify(filterB).init(config);
+    inorder.verify(filterA).doFilter(eq(req), eq(res), capturedChainA.capture());
+    capturedChainA.getValue().doFilter(req, res);
+    inorder.verify(filterB).doFilter(eq(req), eq(res), capturedChainB.capture());
+    capturedChainB.getValue().doFilter(req, res);
+    inorder.verify(chain).doFilter(req, res);
+
+    filterProxy.destroy();
+    inorder.verify(filterA).destroy();
+    inorder.verify(filterB).destroy();
   }
 
   @Test
   public void postponedLoading() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req1 = new FakeHttpServletRequest();
     HttpServletRequest req2 = new FakeHttpServletRequest();
     HttpServletResponse res1 = new FakeHttpServletResponse();
     HttpServletResponse res2 = new FakeHttpServletResponse();
 
-    IMocksControl mockControl = ems.createStrictControl();
-    FilterChain chain = mockControl.createMock("chain", FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = new Capture<>();
-    Capture<FilterChain> capturedChainA2 = new Capture<>();
-    Capture<FilterChain> capturedChainB = new Capture<>();
+    ArgumentCaptor<FilterChain> capturedChainA1 = ArgumentCaptor.forClass(FilterChain.class);
+    ArgumentCaptor<FilterChain> capturedChainA2 = ArgumentCaptor.forClass(FilterChain.class);
+    ArgumentCaptor<FilterChain> capturedChainB = ArgumentCaptor.forClass(FilterChain.class);
 
-    AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
-    AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
+    AllRequestFilter filterA = mock(AllRequestFilter.class);
+    AllRequestFilter filterB = mock(AllRequestFilter.class);
 
-    filterA.init(config);
-    filterA.doFilter(eq(req1), eq(res1), capture(capturedChainA1));
-    chain.doFilter(req1, res1);
-
-    filterA.doFilter(eq(req2), eq(res2), capture(capturedChainA2));
-    filterB.init(config); // <-- This is crucial part. filterB got loaded
-    // after filterProxy's init finished. Nonetheless filterB gets initialized.
-    filterB.doFilter(eq(req2), eq(res2), capture(capturedChainB));
-    chain.doFilter(req2, res2);
-
-    filterA.destroy();
-    filterB.destroy();
-
-    ems.replayAll();
+    InOrder inorder = inOrder(filterA, filterB, chain);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     addFilter(filterA);
 
     filterProxy.init(config);
     filterProxy.doFilter(req1, res1, chain);
+    inorder.verify(filterA).init(config);
+    inorder.verify(filterA).doFilter(eq(req1), eq(res1), capturedChainA1.capture());
     capturedChainA1.getValue().doFilter(req1, res1);
+    inorder.verify(chain).doFilter(req1, res1);
 
     addFilter(filterB); // <-- Adds filter after filterProxy's init got called.
     filterProxy.doFilter(req2, res2, chain);
+    // after filterProxy's init finished. Nonetheless filterB gets initialized.
+    inorder.verify(filterA).doFilter(eq(req2), eq(res2), capturedChainA2.capture());
     capturedChainA2.getValue().doFilter(req2, res2);
+    inorder.verify(filterB).init(config); // <-- This is crucial part. filterB got loaded
+    inorder.verify(filterB).doFilter(eq(req2), eq(res2), capturedChainB.capture());
     capturedChainB.getValue().doFilter(req2, res2);
+    inorder.verify(chain).doFilter(req2, res2);
 
     filterProxy.destroy();
-
-    ems.verifyAll();
+    inorder.verify(filterA).destroy();
+    inorder.verify(filterB).destroy();
   }
 
   @Test
   public void dynamicUnloading() throws Exception {
-    EasyMockSupport ems = new EasyMockSupport();
-
-    FilterConfig config = ems.createMock(FilterConfig.class);
+    FilterConfig config = mock(FilterConfig.class);
     HttpServletRequest req1 = new FakeHttpServletRequest();
     HttpServletRequest req2 = new FakeHttpServletRequest();
     HttpServletRequest req3 = new FakeHttpServletRequest();
@@ -298,64 +267,62 @@
     HttpServletResponse res2 = new FakeHttpServletResponse();
     HttpServletResponse res3 = new FakeHttpServletResponse();
 
-    Plugin plugin = ems.createMock(Plugin.class);
+    Plugin plugin = mock(Plugin.class);
 
-    IMocksControl mockControl = ems.createStrictControl();
-    FilterChain chain = mockControl.createMock("chain", FilterChain.class);
+    FilterChain chain = mock(FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = new Capture<>();
-    Capture<FilterChain> capturedChainB1 = new Capture<>();
-    Capture<FilterChain> capturedChainB2 = new Capture<>();
+    ArgumentCaptor<FilterChain> capturedChainA1 = ArgumentCaptor.forClass(FilterChain.class);
+    ArgumentCaptor<FilterChain> capturedChainB1 = ArgumentCaptor.forClass(FilterChain.class);
+    ArgumentCaptor<FilterChain> capturedChainB2 = ArgumentCaptor.forClass(FilterChain.class);
 
-    AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
-    AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
-
-    filterA.init(config);
-    filterB.init(config);
-
-    filterA.doFilter(eq(req1), eq(res1), capture(capturedChainA1));
-    filterB.doFilter(eq(req1), eq(res1), capture(capturedChainB1));
-    chain.doFilter(req1, res1);
-
-    filterA.destroy(); // Cleaning up of filterA after it got unloaded
-
-    filterB.doFilter(eq(req2), eq(res2), capture(capturedChainB2));
-    chain.doFilter(req2, res2);
-
-    filterB.destroy(); // Cleaning up of filterA after it got unloaded
-
-    chain.doFilter(req3, res3);
-
-    ems.replayAll();
+    AllRequestFilter filterA = mock(AllRequestFilter.class);
+    AllRequestFilter filterB = mock(AllRequestFilter.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     ReloadableRegistrationHandle<AllRequestFilter> handleFilterA = addFilter(filterA);
     ReloadableRegistrationHandle<AllRequestFilter> handleFilterB = addFilter(filterB);
 
+    InOrder inorder = inOrder(filterA, filterB, chain);
+
     filterProxy.init(config);
 
+    inorder.verify(filterA).init(config);
+    inorder.verify(filterB).init(config);
+
     // Request #1 with filterA and filterB
     filterProxy.doFilter(req1, res1, chain);
+    inorder.verify(filterA).doFilter(eq(req1), eq(res1), capturedChainA1.capture());
     capturedChainA1.getValue().doFilter(req1, res1);
+    inorder.verify(filterB).doFilter(eq(req1), eq(res1), capturedChainB1.capture());
     capturedChainB1.getValue().doFilter(req1, res1);
+    inorder.verify(chain).doFilter(req1, res1);
 
     // Unloading filterA
     handleFilterA.remove();
     filterProxy.onStopPlugin(plugin);
 
-    // Request #1 only with filterB
+    inorder.verify(filterA).destroy(); // Cleaning up of filterA after it got unloaded
+
+    // Request #2 only with filterB
     filterProxy.doFilter(req2, res2, chain);
-    capturedChainA1.getValue().doFilter(req2, res2);
+
+    inorder.verify(filterB).doFilter(eq(req2), eq(res2), capturedChainB2.capture());
+    inorder.verify(filterA, never()).doFilter(eq(req2), eq(res2), any(FilterChain.class));
+    capturedChainB2.getValue().doFilter(req2, res2);
+    inorder.verify(chain).doFilter(req2, res2);
 
     // Unloading filterB
     handleFilterB.remove();
     filterProxy.onStopPlugin(plugin);
 
-    // Request #1 with no additional filters
+    inorder.verify(filterB).destroy(); // Cleaning up of filterA after it got unloaded
+
+    // Request #3 with no additional filters
     filterProxy.doFilter(req3, res3, chain);
+    inorder.verify(chain).doFilter(req3, res3);
+    inorder.verify(filterA, never()).doFilter(eq(req2), eq(res2), any(FilterChain.class));
+    inorder.verify(filterB, never()).doFilter(eq(req2), eq(res2), any(FilterChain.class));
 
     filterProxy.destroy();
-
-    ems.verifyAll();
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index adf072e..d751890 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -8,19 +8,19 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//java/com/google/gerrit/util/http",
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:gson",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:jimfs",
         "//lib:junit",
-        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:servlet-api-without-neverlink",
         "//lib:soy",
-        "//lib/easymock",
         "//lib/guice",
         "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/mockito",
         "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
index e19085d..f012ee3 100644
--- a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.httpd.RemoteUserUtil.extractUsername;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RemoteUserUtilTest extends GerritBaseTests {
+public class RemoteUserUtilTest {
   @Test
   public void testExtractUsername() {
     assertThat(extractUsername(null)).isNull();
diff --git a/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java b/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
index 2de3788..684a241 100644
--- a/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
+++ b/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import org.junit.Test;
 
-public class ContextMapperTest extends GerritBaseTests {
+public class ContextMapperTest {
 
   private static final String CONTEXT = "/context";
   private static final String PLUGIN_NAME = "my-plugin";
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
new file mode 100644
index 0000000..15c66eb
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -0,0 +1,77 @@
+// 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.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
+
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import java.util.HashMap;
+import org.junit.Test;
+
+public class IndexHtmlUtilTest {
+
+  @Test
+  public void noPathAndNoCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
+        .containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
+  }
+
+  @Test
+  public void pathAndNoCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/gerrit/",
+                null,
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
+  }
+
+  @Test
+  public void noPathAndCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/",
+                "http://my-cdn.com/foo/bar/",
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly(
+            "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
+  }
+
+  @Test
+  public void pathAndCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/gerrit",
+                "http://my-cdn.com/foo/bar/",
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly(
+            "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
+  }
+
+  private static SanitizedContent ordain(String s) {
+    return UnsafeSanitizedContentOrdainer.ordainAsSafe(
+        s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 340f690..77ab58b 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,66 +15,52 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
-import com.google.gerrit.testing.GerritBaseTests;
-import java.net.URISyntaxException;
-import java.util.Map;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.api.config.Config;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import org.junit.Test;
 
-public class IndexServletTest extends GerritBaseTests {
-  static class TestIndexServlet extends IndexServlet {
-    private static final long serialVersionUID = 1L;
-
-    TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
-        throws URISyntaxException {
-      super(canonicalURL, cdnPath, faviconPath);
-    }
-
-    String getIndexSource() {
-      return new String(indexSource, UTF_8);
-    }
-  }
+public class IndexServletTest {
 
   @Test
-  public void noPathAndNoCDN() throws URISyntaxException {
-    Map<String, Object> data = IndexServlet.getTemplateData("http://example.com/", null, null);
-    assertThat(data.get("canonicalPath")).isEqualTo("");
-    assertThat(data.get("staticResourcePath").toString()).isEqualTo("");
-  }
+  public void renderTemplate() throws Exception {
+    Accounts accountsApi = mock(Accounts.class);
+    when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
 
-  @Test
-  public void pathAndNoCDN() throws URISyntaxException {
-    Map<String, Object> data =
-        IndexServlet.getTemplateData("http://example.com/gerrit/", null, null);
-    assertThat(data.get("canonicalPath")).isEqualTo("/gerrit");
-    assertThat(data.get("staticResourcePath").toString()).isEqualTo("/gerrit");
-  }
+    Server serverApi = mock(Server.class);
+    when(serverApi.getVersion()).thenReturn("123");
+    when(serverApi.topMenus()).thenReturn(ImmutableList.of());
+    ServerInfo serverInfo = new ServerInfo();
+    serverInfo.defaultTheme = "my-default-theme";
+    when(serverApi.getInfo()).thenReturn(serverInfo);
 
-  @Test
-  public void noPathAndCDN() throws URISyntaxException {
-    Map<String, Object> data =
-        IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/", null);
-    assertThat(data.get("canonicalPath")).isEqualTo("");
-    assertThat(data.get("staticResourcePath").toString()).isEqualTo("http://my-cdn.com/foo/bar/");
-  }
+    Config configApi = mock(Config.class);
+    when(configApi.server()).thenReturn(serverApi);
 
-  @Test
-  public void pathAndCDN() throws URISyntaxException {
-    Map<String, Object> data =
-        IndexServlet.getTemplateData(
-            "http://example.com/gerrit", "http://my-cdn.com/foo/bar/", null);
-    assertThat(data.get("canonicalPath")).isEqualTo("/gerrit");
-    assertThat(data.get("staticResourcePath").toString()).isEqualTo("http://my-cdn.com/foo/bar/");
-  }
+    GerritApi gerritApi = mock(GerritApi.class);
+    when(gerritApi.accounts()).thenReturn(accountsApi);
+    when(gerritApi.config()).thenReturn(configApi);
 
-  @Test
-  public void renderTemplate() throws URISyntaxException {
     String testCanonicalUrl = "foo-url";
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
-    TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL);
-    String output = servlet.getIndexSource();
+    IndexServlet servlet =
+        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi);
+
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    servlet.doGet(new FakeHttpServletRequest(), response);
+
+    String output = response.getActualBodyString();
     assertThat(output).contains("<!DOCTYPE html>");
     assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
     assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
@@ -84,5 +70,12 @@
                 + testCanonicalUrl
                 + "/"
                 + testFaviconURL);
+    assertThat(output)
+        .contains(
+            "window.INITIAL_DATA = JSON.parse("
+                + "'\\x7b\\x22\\/config\\/server\\/version\\x22: \\x22123\\x22, "
+                + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
+                + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
+                + "\\x5b\\x5d\\x7d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index cfcc1d0..dd594d6 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
@@ -28,7 +29,6 @@
 import com.google.common.jimfs.Configuration;
 import com.google.common.jimfs.Jimfs;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import java.io.ByteArrayInputStream;
@@ -45,7 +45,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ResourceServletTest extends GerritBaseTests {
+public class ResourceServletTest {
   private static Cache<Path, Resource> newCache(int size) {
     return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
   }
@@ -336,8 +336,8 @@
   }
 
   private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
-    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
-    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
+    assertWithMessage("hits").that(cache.stats().hitCount()).isEqualTo(hits);
+    assertWithMessage("misses").that(cache.stats().missCount()).isEqualTo(misses);
   }
 
   private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
diff --git a/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
index fa3eaea..fb1ebd9 100644
--- a/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
+++ b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HttpLogRedactTest extends GerritBaseTests {
+public class HttpLogRedactTest {
   @Test
   public void redactAuth() {
     assertThat(LogRedactUtil.redactQueryString("query=status:open")).isEqualTo("query=status:open");
diff --git a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
index 30d318b..a550ac7 100644
--- a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
+++ b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
@@ -15,21 +15,20 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import org.junit.Test;
 
-public class ParameterParserTest extends GerritBaseTests {
+public class ParameterParserTest {
   @Test
   public void convertFormToJson() throws BadRequestException {
     JsonObject obj =
@@ -110,35 +109,26 @@
   public void rejectDuplicateMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=PUT&$m=DELETE");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
   }
 
   @Test
   public void rejectDuplicateContentType() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$ct=json&$ct=string");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
   }
 
   @Test
   public void rejectInvalidMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=CONNECT");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
   }
 }
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index a1f60de..861e768 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -10,11 +10,12 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/query/testing",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:junit",
         "//lib/antlr:java-runtime",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index d6b8421..698e00a 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -18,13 +18,13 @@
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-public class SchemaUtilTest extends GerritBaseTests {
+public class SchemaUtilTest {
   static class TestSchemas {
     static final Schema<String> V1 = schema();
     static final Schema<String> V2 = schema();
@@ -43,9 +43,9 @@
     assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
     assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
     assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
-
-    exception.expect(IllegalArgumentException.class);
-    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SchemaUtil.schemasFromClass(TestSchemas.class, Object.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 21098b3..16828dd 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
index 805f31c..2d2c99e 100644
--- a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertSame;
@@ -61,8 +63,9 @@
     assertSame(f, f.copy(Collections.emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Expected 0 children");
-    f.copy(Collections.singleton(f("owner", "bob")));
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> f.copy(Collections.singleton(f("owner", "bob"))));
+    assertThat(thrown).hasMessageThat().contains("Expected 0 children");
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
new file mode 100644
index 0000000..7064f64
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.OrSource;
+import java.util.Collection;
+import java.util.Iterator;
+import org.junit.Test;
+
+/**
+ * Tests that boolean data sources are lazy in that they don't call {@link ResultSet#toList()} or
+ * {@link ResultSet#toList()}. This is necessary because it allows Gerrit to send multiple queries
+ * to the index in parallel, have the results come in asynchronously and wait for them only when we
+ * call aforementioned methods on the {@link ResultSet}.
+ */
+public class LazyDataSourceTest {
+
+  /** Helper to avoid a mock which would be hard to create because of the type inference. */
+  static class LazyPredicate extends Predicate<ChangeData> implements ChangeDataSource {
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() {
+      return new FailingResultSet<>();
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return new FailingResultSet<>();
+    }
+
+    @Override
+    public Predicate<ChangeData> copy(Collection<? extends Predicate<ChangeData>> children) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public int hashCode() {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean hasChange() {
+      throw new UnsupportedOperationException("not implemented");
+    }
+  }
+
+  /** Implementation that throws {@link AssertionError} when accessing results. */
+  static class FailingResultSet<T> implements ResultSet<T> {
+    @Override
+    public Iterator<T> iterator() {
+      throw new AssertionError(
+          "called iterator() on the result set, but shouldn't have because the data source must be lazy");
+    }
+
+    @Override
+    public ImmutableList<T> toList() {
+      throw new AssertionError(
+          "called toList() on the result set, but shouldn't have because the data source must be lazy");
+    }
+
+    @Override
+    public void close() {
+      // No-op
+    }
+  }
+
+  @Test
+  public void andSourceIsLazy() {
+    AndSource<ChangeData> and = new AndSource<>(ImmutableList.of(new LazyPredicate()));
+    ResultSet<ChangeData> resultSet = and.read();
+    assertThrows(AssertionError.class, () -> resultSet.toList());
+  }
+
+  @Test
+  public void orSourceIsLazy() {
+    OrSource or = new OrSource(ImmutableList.of(new LazyPredicate()));
+    ResultSet<ChangeData> resultSet = or.read();
+    assertThrows(AssertionError.class, () -> resultSet.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
index d10d2df..3d1839d 100644
--- a/javatests/com/google/gerrit/index/query/NotPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.Collections;
 import java.util.List;
@@ -50,26 +50,14 @@
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("clear", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
+    assertOnlyChild("clear", p, n);
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove(0)", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
+    assertOnlyChild("remove(0)", p, n);
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove()", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
+    assertOnlyChild("remove()", p, n);
   }
 
   private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
@@ -112,18 +100,11 @@
     assertNotSame(n, n.copy(sb));
     assertEquals(sb, n.copy(sb).getChildren());
 
-    try {
-      n.copy(Collections.emptyList());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> n.copy(Collections.emptyList()));
+    assertEquals("Expected exactly one child", e.getMessage());
 
-    try {
-      n.copy(and(a, b).getChildren());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    e = assertThrows(IllegalArgumentException.class, () -> n.copy(and(a, b).getChildren()));
+    assertEquals("Expected exactly one child", e.getMessage());
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 255a3f8..1cbcb75 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 2295a60..3ec7f13 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Ignore;
 
 @Ignore
-public abstract class PredicateTest extends GerritBaseTests {
+public abstract class PredicateTest {
   protected static final class TestPredicate extends OperatorPredicate<String> {
     protected TestPredicate(String name, String value) {
       super(name, value);
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
index 6a397dc..f653759 100644
--- a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.truth.ThrowableSubject;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collection;
 import java.util.Objects;
 import org.junit.Test;
 
-public class QueryBuilderTest extends GerritBaseTests {
+public class QueryBuilderTest {
   private static class TestPredicate extends Predicate<Object> {
     private final String field;
     private final String value;
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index b4dd1ee..776a2c4 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.index.query;
 
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.index.query.QueryParser.AND;
 import static com.google.gerrit.index.query.QueryParser.COLON;
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
@@ -22,12 +21,12 @@
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.antlr.runtime.tree.Tree;
 import org.junit.Test;
 
-public class QueryParserTest extends GerritBaseTests {
+public class QueryParserTest {
   @Test
   public void fieldNameAndValue() throws Exception {
     Tree r = parse("project:tools/gerrit");
@@ -206,11 +205,6 @@
   }
 
   private static void assertParseFails(String query) {
-    try {
-      parse(query);
-      assert_().fail("expected parse to fail: %s", query);
-    } catch (QueryParseException e) {
-      // Expected.
-    }
+    assertThrows(QueryParseException.class, () -> parse(query));
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/BUILD b/javatests/com/google/gerrit/integration/git/BUILD
index 4f1be09..28755af 100644
--- a/javatests/com/google/gerrit/integration/git/BUILD
+++ b/javatests/com/google/gerrit/integration/git/BUILD
@@ -1,6 +1,12 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
+    srcs = ["GitProtocolV2IT.java"],
+    group = "protocol-v2",
+    labels = ["git-protocol-v2"],
+)
+
+acceptance_tests(
     srcs = ["UploadArchiveIT.java"],
     group = "upload-archive",
     labels = ["git-upload-archive"],
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
new file mode 100644
index 0000000..8577c16
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -0,0 +1,382 @@
+// 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.integration.git;
+
+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.deny;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
+import com.google.gerrit.acceptance.GitClientVersion;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import java.io.File;
+import java.net.InetSocketAddress;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+@UseSsh
+public class GitProtocolV2IT extends StandaloneSiteTest {
+  private static final String ADMIN_PASSWORD = "secret";
+  private final String[] SSH_KEYGEN_CMD =
+      new String[] {"ssh-keygen", "-t", "rsa", "-q", "-P", "", "-f"};
+  private final String[] GIT_LS_REMOTE =
+      new String[] {"git", "-c", "protocol.version=2", "ls-remote", "-o", "trace=12345"};
+  private final String[] GIT_CLONE_MIRROR =
+      new String[] {"git", "-c", "protocol.version=2", "clone", "--mirror"};
+  private final String[] GIT_FETCH = new String[] {"git", "-c", "protocol.version=2", "fetch"};
+  private final String[] GIT_INIT = new String[] {"git", "init"};
+  private final String GIT_SSH_COMMAND =
+      "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i";
+
+  @Inject private GerritApi gApi;
+  @Inject private AccountCreator accountCreator;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
+  @Inject private @GerritServerConfig Config config;
+  @Inject private AllProjectsName allProjectsName;
+
+  @BeforeClass
+  public static void assertGitClientVersion() throws Exception {
+    // Minimum required git-core version that supports wire protocol v2 is 2.18.0
+    GitClientVersion requiredGitVersion = new GitClientVersion(2, 18, 0);
+    GitClientVersion actualGitVersion =
+        new GitClientVersion(execute(ImmutableList.of("git", "version"), new File("/")));
+    // If git client version cannot be updated, consider to skip this tests. Due to
+    // an existing issue in bazel, JUnit assumption violation feature cannot be used.
+    assertThat(actualGitVersion).isAtLeast(requiredGitVersion);
+  }
+
+  @Test
+  public void testGitWireProtocolV2WithSsh() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Create project
+      Project.NameKey project = Project.nameKey("foo");
+      gApi.projects().create(project.get());
+
+      // Set up project permission
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Set protocol.version=2 in target repository
+      execute(
+          ImmutableList.of("git", "config", "protocol.version", "2"),
+          sitePaths.site_path.resolve("git").resolve(project.get() + Constants.DOT_GIT).toFile());
+
+      // Retrieve HTTP url
+      String url = config.getString("gerrit", null, "canonicalweburl");
+      String urlDestinationTemplate =
+          url.substring(0, 7)
+              + "%s:secret@"
+              + url.substring(7, url.length())
+              + "/a/"
+              + project.get();
+
+      // Retrieve SSH host and port
+      String sshDestinationTemplate =
+          "ssh://%s@" + sshAddress.getHostName() + ":" + sshAddress.getPort() + "/" + project.get();
+
+      // Admin user was already created by the base class
+      setUpUserAuthentication(admin.username());
+
+      // Create non-admin user
+      TestAccount user = accountCreator.user();
+      setUpUserAuthentication(user.username());
+
+      // Prepare data for new change on master branch
+      ChangeInput in = new ChangeInput(project.get(), "master", "Test public change");
+      in.newBranch = true;
+
+      // Create new change and retrieve SHA1 for the created patch set
+      String commit =
+          gApi.changes()
+              .id(gApi.changes().create(in).info().changeId)
+              .current()
+              .commit(false)
+              .commit;
+
+      // Prepare new change on secret branch
+      in = new ChangeInput(project.get(), ADMIN_PASSWORD, "Test secret change");
+      in.newBranch = true;
+
+      // Create new change and retrieve SHA1 for the created patch set
+      String secretCommit =
+          gApi.changes()
+              .id(gApi.changes().create(in).info().changeId)
+              .current()
+              .commit(false)
+              .commit;
+
+      // Read refs from target repository using git wire protocol v2 over HTTP for admin user
+      String out =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_LS_REMOTE)
+                  .add(String.format(urlDestinationTemplate, admin.username()))
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertGitProtocolV2Refs(commit, out);
+      assertThat(out).contains(secretCommit);
+
+      // Read refs from target repository using git wire protocol v2 over SSH for admin user
+      out =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_LS_REMOTE)
+                  .add(String.format(sshDestinationTemplate, admin.username()))
+                  .build(),
+              ImmutableMap.of(
+                  "GIT_SSH_COMMAND",
+                  GIT_SSH_COMMAND
+                      + sitePaths.data_dir.resolve(String.format("id_rsa_%s", admin.username())),
+                  "GIT_TRACE_PACKET",
+                  "1"));
+
+      assertGitProtocolV2Refs(commit, out);
+      assertThat(out).contains(secretCommit);
+
+      // Read refs from target repository using git wire protocol v2 over HTTP for non-admin user
+      out =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_LS_REMOTE)
+                  .add(String.format(urlDestinationTemplate, user.username()))
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertGitProtocolV2Refs(commit, out);
+      assertThat(out).doesNotContain(secretCommit);
+
+      // Read refs from target repository using git wire protocol v2 over SSH for non-admin user
+      out =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_LS_REMOTE)
+                  .add(String.format(sshDestinationTemplate, user.username()))
+                  .build(),
+              ImmutableMap.of(
+                  "GIT_SSH_COMMAND",
+                  GIT_SSH_COMMAND
+                      + sitePaths.data_dir.resolve(String.format("id_rsa_%s", user.username())),
+                  "GIT_TRACE_PACKET",
+                  "1"));
+
+      assertGitProtocolV2Refs(commit, out);
+      assertThat(out).doesNotContain(secretCommit);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2HidesRefMetaConfig() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+      String url = config.getString("gerrit", null, "canonicalweburl");
+
+      // Create project
+      Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
+      gApi.projects().create(allRefsVisibleProject.get());
+
+      // Set protocol.version=2 in target repository
+      execute(
+          ImmutableList.of("git", "config", "protocol.version", "2"),
+          sitePaths
+              .site_path
+              .resolve("git")
+              .resolve(allRefsVisibleProject.get() + Constants.DOT_GIT)
+              .toFile());
+
+      // Set up project permission to allow reading all refs
+      projectOperations
+          .project(allRefsVisibleProject)
+          .forUpdate()
+          .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/changes/*")
+                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(allRefsVisibleProject.get(), "master", "Test public change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+      String visibleChangeNumberMetaRef = RefNames.changeMetaRef(changeId);
+
+      // Read refs from target repository using git wire protocol v2 over HTTP anonymously
+      String outAnonymousLsRemote =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_CLONE_MIRROR)
+                  .add(url + "/" + allRefsVisibleProject.get())
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertThat(outAnonymousLsRemote).contains("git< version 2");
+      assertThat(outAnonymousLsRemote).doesNotContain(RefNames.REFS_CONFIG);
+      assertThat(outAnonymousLsRemote).contains(visibleChangeNumberRef);
+      assertThat(outAnonymousLsRemote).contains(visibleChangeNumberMetaRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Set protocol.version=2 in target repository
+      execute(
+          ImmutableList.of("git", "config", "protocol.version", "2"),
+          sitePaths
+              .site_path
+              .resolve("git")
+              .resolve(privateProject.get() + Constants.DOT_GIT)
+              .toFile());
+
+      // Disallow general read permissions for anonymous users
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Set up project permission to allow registered users fetching changes/*
+      projectOperations
+          .project(privateProject)
+          .forUpdate()
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/changes/*")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_FETCH)
+                  .add(urlWithCredentials + "/" + privateProject.get())
+                  .add(visibleChangeNumberRef)
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+    }
+  }
+
+  private void setUpUserAuthentication(String username) throws Exception {
+    // Assign HTTP password to user
+    gApi.accounts().id(username).setHttpPassword(ADMIN_PASSWORD);
+
+    // Generate private/public key for user
+    execute(
+        ImmutableList.<String>builder()
+            .add(SSH_KEYGEN_CMD)
+            .add(String.format("id_rsa_%s", username))
+            .build());
+
+    // Read the content of generated public key and add it for the user in Gerrit
+    gApi.accounts()
+        .id(username)
+        .addSshKey(
+            new String(
+                java.nio.file.Files.readAllBytes(
+                    sitePaths.data_dir.resolve(String.format("id_rsa_%s.pub", username))),
+                UTF_8));
+  }
+
+  private static void assertGitProtocolV2Refs(String commit, String out) {
+    assertThat(out).contains("git< version 2");
+    assertThat(out).contains("refs/changes/01/1/1");
+    assertThat(out).contains("refs/changes/01/1/meta");
+    assertThat(out).contains(commit);
+  }
+
+  private String execute(String... cmds) throws Exception {
+    return execute(ImmutableList.<String>builder().add(cmds).build());
+  }
+
+  private String execute(ImmutableList<String> cmd) throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), ImmutableMap.of());
+  }
+
+  private String execute(ImmutableList<String> cmd, ImmutableMap<String, String> env)
+      throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), env);
+  }
+
+  private static String execute(ImmutableList<String> cmd, File dir) throws Exception {
+    return execute(cmd, dir, ImmutableMap.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index 18a78b0..2968111 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -27,11 +27,11 @@
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import java.io.BufferedInputStream;
 import java.io.IOException;
@@ -133,7 +133,7 @@
 
   private void setUpTestHarness(ServerContext ctx) throws RestApiException, Exception {
     ctx.getInjector().injectMembers(this);
-    project = new Project.NameKey("upload-archive-project-test");
+    project = Project.nameKey("upload-archive-project-test");
     gApi.projects().create(project.get());
     setUpAuthentication();
     sshDestination =
diff --git a/javatests/com/google/gerrit/json/BUILD b/javatests/com/google/gerrit/json/BUILD
index 4894cdb..575f575 100644
--- a/javatests/com/google/gerrit/json/BUILD
+++ b/javatests/com/google/gerrit/json/BUILD
@@ -5,6 +5,9 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/json",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
         "//lib:guava",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java b/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java
index a8488a9..05a9cfb 100644
--- a/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java
+++ b/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.json;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.json.JavaSqlTimestampHelper.parseTimestamp;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import java.text.SimpleDateFormat;
 import java.util.TimeZone;
@@ -73,12 +73,7 @@
   }
 
   private static void assertInvalid(String input) {
-    try {
-      parseTimestamp(input);
-      assert_().fail("Expected IllegalArgumentException for: " + input);
-    } catch (IllegalArgumentException e) {
-      // Expected;
-    }
+    assertThrows(IllegalArgumentException.class, () -> parseTimestamp(input));
   }
 
   private String reformat(String input) {
diff --git a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
new file mode 100644
index 0000000..dd710f9
--- /dev/null
+++ b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
@@ -0,0 +1,80 @@
+// 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.json;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gson.Gson;
+import org.junit.Test;
+
+public class JsonEnumMappingTest {
+
+  // Use the regular, pre-configured Gson object we use throughout the Gerrit server to ensure that
+  // the EnumTypeAdapterFactory is properly set up.
+  private final Gson gson = OutputFormat.JSON.newGson();
+
+  @Test
+  public void nullCanBeWrittenAndParsedBack() {
+    String resultingJson = gson.toJson(null, TestEnum.class);
+    TestEnum value = gson.fromJson(resultingJson, TestEnum.class);
+    assertThat(value).isNull();
+  }
+
+  @Test
+  public void enumValueCanBeWrittenAndParsedBack() {
+    String resultingJson = gson.toJson(TestEnum.ONE, TestEnum.class);
+    TestEnum value = gson.fromJson(resultingJson, TestEnum.class);
+    assertThat(value).isEqualTo(TestEnum.ONE);
+  }
+
+  @Test
+  public void enumValueCanBeParsed() {
+    TestData data = gson.fromJson("{\"value\":\"ONE\"}", TestData.class);
+    assertThat(data.value).isEqualTo(TestEnum.ONE);
+  }
+
+  @Test
+  public void mixedCaseEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"oNe\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void lowerCaseEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"one\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void notExistingEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"FOUR\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void emptyEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  private static class TestData {
+    TestEnum value;
+  }
+
+  private enum TestEnum {
+    ONE,
+    TWO
+  }
+}
diff --git a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
new file mode 100644
index 0000000..2699c3b
--- /dev/null
+++ b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
@@ -0,0 +1,33 @@
+// 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.json;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gson.JsonPrimitive;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class SqlTimestampDeserializerTest {
+
+  private final SqlTimestampDeserializer deserializer = new SqlTimestampDeserializer();
+
+  @Test
+  public void emptyStringIsDeserializedToMagicTimestamp() {
+    Timestamp timestamp = deserializer.deserialize(new JsonPrimitive(""), Timestamp.class, null);
+    assertThat(timestamp).isEqualTo(TimeUtil.never());
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index bcff6a7..3c84420 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -16,9 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -26,7 +25,7 @@
 import org.junit.Ignore;
 
 @Ignore
-public class AbstractParserTest extends GerritBaseTests {
+public class AbstractParserTest {
   protected static final String CHANGE_URL =
       "https://gerrit-review.googlesource.com/c/project/+/123";
 
@@ -56,7 +55,7 @@
     Comment c =
         new Comment(
             new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
+            Account.id(0),
             new Timestamp(0L),
             (short) 0,
             message,
@@ -70,7 +69,7 @@
     Comment c =
         new Comment(
             new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
+            Account.id(0),
             new Timestamp(0L),
             (short) 0,
             message,
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
index 53ff1fe..da26123 100644
--- a/javatests/com/google/gerrit/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class AddressTest extends GerritBaseTests {
+public class AddressTest {
   @Test
   public void parse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
@@ -97,12 +96,9 @@
   }
 
   private void assertInvalid(String in) {
-    try {
-      Address.parse(in);
-      fail("Expected IllegalArgumentException for " + in);
-    } catch (IllegalArgumentException e) {
-      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
-    }
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> Address.parse(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("Invalid email address: " + in);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/mail/BUILD b/javatests/com/google/gerrit/mail/BUILD
index b1c9712..bd2c478 100644
--- a/javatests/com/google/gerrit/mail/BUILD
+++ b/javatests/com/google/gerrit/mail/BUILD
@@ -8,15 +8,15 @@
     ),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/mail",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gson",
         "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/commons:codec",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
index d630bd6..345cb05 100644
--- a/javatests/com/google/gerrit/mail/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.List;
 import org.junit.Ignore;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index cdc8d7a..2d2c2ea 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -16,14 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
 import org.junit.Test;
 
-public class MailHeaderParserTest extends GerritBaseTests {
+public class MailHeaderParserTest {
   @Test
   public void parseMetadataFromHeader() {
     // This tests if the metadata parser is able to parse metadata from the
diff --git a/javatests/com/google/gerrit/mail/ParserUtilTest.java b/javatests/com/google/gerrit/mail/ParserUtilTest.java
index ed40a57..47a5367 100644
--- a/javatests/com/google/gerrit/mail/ParserUtilTest.java
+++ b/javatests/com/google/gerrit/mail/ParserUtilTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ParserUtilTest extends GerritBaseTests {
+public class ParserUtilTest {
   @Test
   public void trimQuotationLineOnMessageWithoutQuoatationLine() throws Exception {
     assertThat(ParserUtil.trimQuotation("One line")).isEqualTo("One line");
diff --git a/javatests/com/google/gerrit/mail/RawMailParserTest.java b/javatests/com/google/gerrit/mail/RawMailParserTest.java
index 9049704..0ab2811 100644
--- a/javatests/com/google/gerrit/mail/RawMailParserTest.java
+++ b/javatests/com/google/gerrit/mail/RawMailParserTest.java
@@ -23,10 +23,9 @@
 import com.google.gerrit.mail.data.QuotedPrintableHeaderMessage;
 import com.google.gerrit.mail.data.RawMailMessage;
 import com.google.gerrit.mail.data.SimpleTextMessage;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RawMailParserTest extends GerritBaseTests {
+public class RawMailParserTest {
   @Test
   public void parseEmail() throws Exception {
     RawMailMessage[] messages =
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
index a5c2152..00d5b41 100644
--- a/javatests/com/google/gerrit/mail/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.entities.Comment;
 import java.util.List;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index a8f5b94..c4737e6 100644
--- a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -39,7 +39,7 @@
           + "when I try to load this change:\n"
           + "\n"
           + "  Error in GET /changes/90018/detail?O=10004\n"
-          + "  com.google.gwtorm.OrmException: java.lang.NullPointerException\n"
+          + "  com.google.gerrit.exceptions.StorageException: java.lang.NullPointerException\n"
           + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
           + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
           + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BUILD b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
index 63d4452..98d12b2 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
@@ -7,7 +7,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/metrics/dropwizard",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index d6bcb62..9b21bf6 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class DropWizardMetricMakerTest extends GerritBaseTests {
+public class DropWizardMetricMakerTest {
   DropWizardMetricMaker metrics =
       new DropWizardMetricMaker(null /* MetricRegistry unused in tests */);
 
diff --git a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 0a5dabf..33919e7 100644
--- a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.metrics.proc;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.codahale.metrics.Counter;
 import com.codahale.metrics.Gauge;
@@ -30,7 +32,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -39,7 +40,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProcMetricModuleTest extends GerritBaseTests {
+public class ProcMetricModuleTest {
   @Inject MetricMaker metrics;
 
   @Inject MetricRegistry registry;
@@ -79,7 +80,9 @@
   public void counter1() {
     Counter1<String> cntr =
         metrics.newCounter(
-            "test/count", new Description("simple test").setCumulative(), Field.ofString("action"));
+            "test/count",
+            new Description("simple test").setCumulative(),
+            Field.ofString("action", Field.ignoreMetadata()).build());
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -104,7 +107,7 @@
             new Description("simple test")
                 .setCumulative()
                 .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
-            Field.ofString("action"));
+            Field.ofString("action", Field.ignoreMetadata()).build());
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -147,14 +150,16 @@
 
   @Test
   public void invalidName1() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid name", new Description("fail")));
   }
 
   @Test
   public void invalidName2() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid/ name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid/ name", new Description("fail")));
   }
 
   @SuppressWarnings({"unchecked", "cast"})
@@ -164,8 +169,8 @@
 
   private <M extends Metric> M get(String name, Class<M> type) {
     Metric m = registry.getMetrics().get(name);
-    assertThat(m).named(name).isNotNull();
-    assertThat(m).named(name).isInstanceOf(type);
+    assertWithMessage(name).that(m).isNotNull();
+    assertWithMessage(name).that(m).isInstanceOf(type);
 
     @SuppressWarnings("unchecked")
     M result = (M) m;
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index e82b884..1445707 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -5,19 +5,17 @@
     name = "pgm_tests",
     srcs = glob(["**/*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/pgm",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/pgm/init/api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/securestore/testing",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:junit",
-        "//lib/easymock",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
index a34007e..e34b578 100644
--- a/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
+++ b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.pgm.init.api;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.replay;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.config.SitePaths;
@@ -37,12 +35,18 @@
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class AllProjectsConfigTest {
   private static final String ALL_PROJECTS = "All-The-Projects";
 
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+  @Mock ConsoleUI ui;
+
   private SitePaths sitePaths;
   private AllProjectsConfig allProjectsConfig;
   private File allProjectsRepoFile;
@@ -70,8 +74,6 @@
 
     InMemorySecureStore secureStore = new InMemorySecureStore();
     InitFlags flags = new InitFlags(sitePaths, secureStore, ImmutableList.of(), false);
-    ConsoleUI ui = createStrictMock(ConsoleUI.class);
-    replay(ui);
     Section.Factory sections =
         (name, subsection) -> new Section(flags, sitePaths, secureStore, ui, name, subsection);
     allProjectsConfig =
diff --git a/javatests/com/google/gerrit/proto/ProtosTest.java b/javatests/com/google/gerrit/proto/ProtosTest.java
index 29e8fe0..550bcc5 100644
--- a/javatests/com/google/gerrit/proto/ProtosTest.java
+++ b/javatests/com/google/gerrit/proto/ProtosTest.java
@@ -14,17 +14,16 @@
 
 package com.google.gerrit.proto;
 
-import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.ByteString;
 import java.util.Arrays;
 import org.junit.Test;
 
-public class ProtosTest extends GerritBaseTests {
+public class ProtosTest {
   @Test
   public void parseUncheckedByteArrayWrongProtoType() {
     ChangeNotesKeyProto proto =
@@ -34,23 +33,17 @@
             .setId(ByteString.copyFromUtf8("foo"))
             .build();
     byte[] bytes = Protos.toByteArray(proto);
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes));
   }
 
   @Test
   public void parseUncheckedByteArrayInvalidData() {
     byte[] bytes = new byte[] {0x00};
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes));
   }
 
   @Test
@@ -74,23 +67,17 @@
             .setId(ByteString.copyFromUtf8("foo"))
             .build();
     byte[] bytes = Protos.toByteArray(proto);
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length));
   }
 
   @Test
   public void parseUncheckedSegmentOfByteArrayInvalidData() {
     byte[] bytes = new byte[] {0x00};
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length));
   }
 
   @Test
@@ -123,23 +110,17 @@
             .setId(ByteString.copyFromUtf8("foo"))
             .build();
     ByteString byteString = Protos.toByteString(proto);
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString));
   }
 
   @Test
   public void parseUncheckedByteStringInvalidData() {
     ByteString byteString = ByteString.copyFrom(new byte[] {0x00});
-    try {
-      Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/reviewdb/client/BUILD b/javatests/com/google/gerrit/reviewdb/client/BUILD
deleted file mode 100644
index bc993d5..0000000
--- a/javatests/com/google/gerrit/reviewdb/client/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob(["*.java"]),
-    deps = [
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib/truth",
-    ],
-)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
deleted file mode 100644
index b8d2b1e..0000000
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.converter;
-
-import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.truth.Truth;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-import java.lang.reflect.Type;
-import java.sql.Timestamp;
-import org.junit.Test;
-
-public class PatchSetProtoConverterTest {
-  private final PatchSetProtoConverter patchSetProtoConverter = PatchSetProtoConverter.INSTANCE;
-
-  @Test
-  public void allValuesConvertedToProto() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-    patchSet.setRevision(new RevId("aabbccddeeff"));
-    patchSet.setUploader(new Account.Id(452));
-    patchSet.setCreatedOn(new Timestamp(930349320L));
-    patchSet.setGroups(ImmutableList.of("group1, group2"));
-    patchSet.setPushCertificate("my push certificate");
-    patchSet.setDescription("This is a patch set description.");
-
-    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
-
-    Entities.PatchSet expectedProto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
-            .setRevision(Entities.RevId.newBuilder().setId("aabbccddeeff"))
-            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
-            .setCreatedOn(930349320L)
-            .setGroups("group1, group2")
-            .setPushCertificate("my push certificate")
-            .setDescription("This is a patch set description.")
-            .build();
-    assertThat(proto).isEqualTo(expectedProto);
-  }
-
-  @Test
-  public void mandatoryValuesConvertedToProto() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-
-    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
-
-    Entities.PatchSet expectedProto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
-            .build();
-    assertThat(proto).isEqualTo(expectedProto);
-  }
-
-  @Test
-  public void allValuesConvertedToProtoAndBackAgain() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-    patchSet.setRevision(new RevId("aabbccddeeff"));
-    patchSet.setUploader(new Account.Id(452));
-    patchSet.setCreatedOn(new Timestamp(930349320L));
-    patchSet.setGroups(ImmutableList.of("group1, group2"));
-    patchSet.setPushCertificate("my push certificate");
-    patchSet.setDescription("This is a patch set description.");
-
-    PatchSet convertedPatchSet =
-        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
-    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
-  }
-
-  @Test
-  public void mandatoryValuesConvertedToProtoAndBackAgain() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-
-    PatchSet convertedPatchSet =
-        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
-    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
-  }
-
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
-  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
-  @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(PatchSet.class)
-        .hasFields(
-            ImmutableMap.<String, Type>builder()
-                .put("id", PatchSet.Id.class)
-                .put("revision", RevId.class)
-                .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
-                .put("groups", String.class)
-                .put("pushCertificate", String.class)
-                .put("description", String.class)
-                .build());
-  }
-}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
deleted file mode 100644
index 2c354be..0000000
--- a/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.converter;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-import org.junit.Test;
-
-public class RevIdProtoConverterTest {
-  private final RevIdProtoConverter revIdProtoConverter = RevIdProtoConverter.INSTANCE;
-
-  @Test
-  public void allValuesConvertedToProto() {
-    RevId revId = new RevId("9903402f303249e");
-
-    Entities.RevId proto = revIdProtoConverter.toProto(revId);
-
-    Entities.RevId expectedProto = Entities.RevId.newBuilder().setId("9903402f303249e").build();
-    assertThat(proto).isEqualTo(expectedProto);
-  }
-
-  @Test
-  public void allValuesConvertedToProtoAndBackAgain() {
-    RevId revId = new RevId("ff3934a320bb");
-
-    RevId convertedRevId = revIdProtoConverter.fromProto(revIdProtoConverter.toProto(revId));
-
-    assertThat(convertedRevId).isEqualTo(revId);
-  }
-
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.RevId proto = Entities.RevId.newBuilder().setId("9903402f303249e").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.RevId> parser = revIdProtoConverter.getParser();
-    Entities.RevId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
-  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
-  @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(RevId.class).hasFields(ImmutableMap.of("id", String.class));
-  }
-}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 2fcc842..b94a709 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -30,30 +30,31 @@
     tags = ["no_windows"],
     visibility = ["//visibility:public"],
     runtime_deps = [
+        "//java/com/google/gerrit/lucene",
         "//lib/bouncycastle:bcprov",
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
         ":custom-truth-subjects",
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/jgit",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/proto/testing",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/account/externalids/testing",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
-        "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
@@ -61,19 +62,21 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/schema/testing",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:assertable-executor",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/ChangeUtilTest.java b/javatests/com/google/gerrit/server/ChangeUtilTest.java
index 5cb474d..5f73d2c 100644
--- a/javatests/com/google/gerrit/server/ChangeUtilTest.java
+++ b/javatests/com/google/gerrit/server/ChangeUtilTest.java
@@ -16,11 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.regex.Pattern;
 import org.junit.Test;
 
-public class ChangeUtilTest extends GerritBaseTests {
+public class ChangeUtilTest {
   @Test
   public void changeMessageUuid() throws Exception {
     Pattern pat = Pattern.compile("^[0-9a-f]{8}_[0-9a-f]{8}$");
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 485de49..1672ce1 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.inject.Scopes.SINGLETON;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -45,7 +44,7 @@
 import org.junit.runner.RunWith;
 
 @RunWith(ConfigSuite.class)
-public class IdentifiedUserTest extends GerritBaseTests {
+public class IdentifiedUserTest {
   @ConfigSuite.Parameter public Config config;
 
   private IdentifiedUser identifiedUser;
@@ -99,8 +98,11 @@
     Injector injector = Guice.createInjector(mod);
     injector.injectMembers(this);
 
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
-    Account.Id ownerId = account.getId();
+    Account account =
+        Account.builder(Account.id(1), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
+    Account.Id ownerId = account.id();
 
     identifiedUser = identifiedUserFactory.create(ownerId);
 
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index c37a945..769370a 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -17,26 +17,24 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountResolver.Result;
 import com.google.gerrit.server.account.AccountResolver.Searcher;
 import com.google.gerrit.server.account.AccountResolver.StringSearcher;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 import org.junit.Test;
 
-public class AccountResolverTest extends GerritBaseTests {
+public class AccountResolverTest {
   private static class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
@@ -86,7 +84,7 @@
     @Override
     public String toString() {
       return accounts.stream()
-          .map(a -> a.getAccount().getId().toString())
+          .map(a -> a.account().id().toString())
           .collect(joining(",", pattern + "(", ")"));
     }
   }
@@ -158,7 +156,7 @@
     // Searchers always short-circuit when finding a non-empty result list, and this one didn't
     // filter out inactive results, so the second searcher never ran.
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
-    assertThat(getOnlyElement(result.asList()).getAccount().isActive()).isFalse();
+    assertThat(getOnlyElement(result.asList()).account().isActive()).isFalse();
     assertThat(filteredInactiveIds(result)).isEmpty();
   }
 
@@ -175,7 +173,7 @@
     // and this one didn't filter out inactive results,
     // so the second searcher never ran.
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
-    assertThat(getOnlyElement(result.asList()).getAccount().isActive()).isFalse();
+    assertThat(getOnlyElement(result.asList()).account().isActive()).isFalse();
     assertThat(filteredInactiveIds(result)).isEmpty();
   }
 
@@ -242,15 +240,14 @@
 
   @Test
   public void asUniqueWithNoResults() throws Exception {
-    try {
-      String input = "foo";
-      ImmutableList<Searcher<?>> searchers = ImmutableList.of();
-      Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
-      search(input, searchers, visibilitySupplier).asUnique();
-      assert_().fail("Expected UnresolvableAccountException");
-    } catch (UnresolvableAccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Account 'foo' not found");
-    }
+    String input = "foo";
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of();
+    Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
+    UnresolvableAccountException thrown =
+        assertThrows(
+            UnresolvableAccountException.class,
+            () -> search(input, searchers, visibilitySupplier).asUnique());
+    assertThat(thrown).hasMessageThat().isEqualTo("Account 'foo' not found");
   }
 
   @Test
@@ -258,22 +255,21 @@
     AccountState account = newAccount(1);
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, account));
-    assertThat(search("foo", searchers, allVisible()).asUnique().getAccount().getId())
-        .isEqualTo(account.getAccount().getId());
+    assertThat(search("foo", searchers, allVisible()).asUnique().account().id())
+        .isEqualTo(account.account().id());
   }
 
   @Test
   public void asUniqueWithMultipleResults() throws Exception {
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
-    try {
-      search("foo", searchers, allVisible()).asUnique();
-      assert_().fail("Expected UnresolvableAccountException");
-    } catch (UnresolvableAccountException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Account 'foo' is ambiguous:\n1: Anonymous Name (1)\n2: Anonymous Name (2)");
-    }
+    UnresolvableAccountException thrown =
+        assertThrows(
+            UnresolvableAccountException.class,
+            () -> search("foo", searchers, allVisible()).asUnique());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Account 'foo' is ambiguous:\n1: Anonymous Name (1)\n2: Anonymous Name (2)");
   }
 
   @Test
@@ -359,17 +355,19 @@
 
   private AccountState newAccount(int id) {
     return AccountState.forAccount(
-        new AllUsersName("All-Users"), new Account(new Account.Id(id), TimeUtil.nowTs()));
+        Account.builder(Account.id(id), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build());
   }
 
   private AccountState newInactiveAccount(int id) {
-    Account a = new Account(new Account.Id(id), TimeUtil.nowTs());
+    Account.Builder a = Account.builder(Account.id(id), TimeUtil.nowTs());
     a.setActive(false);
-    return AccountState.forAccount(new AllUsersName("All-Users"), a);
+    return AccountState.forAccount(a.build());
   }
 
   private static ImmutableSet<Account.Id> ids(int... ids) {
-    return Arrays.stream(ids).mapToObj(Account.Id::new).collect(toImmutableSet());
+    return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
   }
 
   private static Supplier<Predicate<AccountState>> allVisible() {
@@ -377,18 +375,16 @@
   }
 
   private Predicate<AccountState> activityPrediate() {
-    return (AccountState accountState) -> accountState.getAccount().isActive();
+    return (AccountState accountState) -> accountState.account().isActive();
   }
 
   private static Supplier<Predicate<AccountState>> only(int... ids) {
     ImmutableSet<Account.Id> idSet =
-        Arrays.stream(ids).mapToObj(Account.Id::new).collect(toImmutableSet());
-    return () -> a -> idSet.contains(a.getAccount().getId());
+        Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
+    return () -> a -> idSet.contains(a.account().id());
   }
 
   private static ImmutableSet<Account.Id> filteredInactiveIds(Result result) {
-    return result.filteredInactive().stream()
-        .map(a -> a.getAccount().getId())
-        .collect(toImmutableSet());
+    return result.filteredInactive().stream().map(a -> a.account().id()).collect(toImmutableSet());
   }
 }
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 51a34f5..1381c75 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,14 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.junit.Test;
 
-public class AuthorizedKeysTest extends GerritBaseTests {
+public class AuthorizedKeysTest {
   private static final String KEY1 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
           + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
@@ -55,7 +54,7 @@
           + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
           + "w== john.doe@example.com";
 
-  private final Account.Id accountId = new Account.Id(1);
+  private final Account.Id accountId = Account.id(1);
 
   @Test
   public void test() throws Exception {
@@ -151,7 +150,7 @@
 
   private static void assertParse(
       StringBuilder authorizedKeys, List<Optional<AccountSshKey>> expectedKeys) {
-    Account.Id accountId = new Account.Id(1);
+    Account.Id accountId = Account.id(1);
     List<Optional<AccountSshKey>> parsedKeys =
         AuthorizedKeys.parse(accountId, authorizedKeys.toString());
     assertThat(parsedKeys).containsExactlyElementsIn(expectedKeys);
@@ -171,7 +170,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.create(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.create(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return key.sshPublicKey() + "\n";
   }
@@ -182,7 +181,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addInvalidKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.createInvalid(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.createInvalid(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.sshPublicKey() + "\n";
   }
diff --git a/javatests/com/google/gerrit/server/account/DestinationListTest.java b/javatests/com/google/gerrit/server/account/DestinationListTest.java
index e51b041..4188f39 100644
--- a/javatests/com/google/gerrit/server/account/DestinationListTest.java
+++ b/javatests/com/google/gerrit/server/account/DestinationListTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -27,7 +26,7 @@
 import java.util.Set;
 import org.junit.Test;
 
-public class DestinationListTest extends GerritBaseTests {
+public class DestinationListTest {
   public static final String R_FOO = "refs/heads/foo";
   public static final String R_BAR = "refs/heads/bar";
 
@@ -55,11 +54,11 @@
   public static final String LABEL = "label";
   public static final String LABEL2 = "another";
 
-  public static final Branch.NameKey B_FOO = dest(P_MY, R_FOO);
-  public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR);
-  public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
+  public static final BranchNameKey B_FOO = dest(P_MY, R_FOO);
+  public static final BranchNameKey B_BAR = dest(P_SLASH, R_BAR);
+  public static final BranchNameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
 
-  public static final Set<Branch.NameKey> D_SIMPLE = new HashSet<>();
+  public static final Set<BranchNameKey> D_SIMPLE = new HashSet<>();
 
   static {
     D_SIMPLE.clear();
@@ -67,15 +66,15 @@
     D_SIMPLE.add(B_BAR);
   }
 
-  private static Branch.NameKey dest(String project, String ref) {
-    return new Branch.NameKey(new Project.NameKey(project), ref);
+  private static BranchNameKey dest(String project, String ref) {
+    return BranchNameKey.create(Project.nameKey(project), ref);
   }
 
   @Test
   public void testParseSimple() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -83,7 +82,7 @@
   public void testParseWHeader() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, HEADER + F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -91,7 +90,7 @@
   public void testParseWComments() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, C1 + F_SIMPLE + C2, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -99,7 +98,7 @@
   public void testParseFooComment() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, "#" + L_FOO + L_BAR, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).doesNotContain(B_FOO);
     assertThat(branches).contains(B_BAR);
   }
@@ -108,7 +107,7 @@
   public void testParsePaddedFronts() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_F, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -116,7 +115,7 @@
   public void testParsePaddedEnds() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_E, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -124,7 +123,7 @@
   public void testParseComplex() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, L_COMPLEX, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).contains(B_COMPLEX);
   }
 
@@ -140,7 +139,7 @@
   public void testParse2Labels() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
 
     dl.parseLabel(LABEL2, L_COMPLEX, null);
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
index 70887e6..a155d7f 100644
--- a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
+++ b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.AccountGroup;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-public class GroupUUIDTest extends GerritBaseTests {
+public class GroupUUIDTest {
   @Test
   public void createdUuidsForSameInputShouldBeDifferent() {
     String groupName = "Users";
diff --git a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
index 9a0c9cb9..3443720 100644
--- a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
+++ b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.base.Strings;
-import com.google.gerrit.testing.GerritBaseTests;
-import org.apache.commons.codec.DecoderException;
+import com.google.gerrit.server.account.HashedPassword.DecoderException;
 import org.junit.Test;
 
-public class HashedPasswordTest extends GerritBaseTests {
+public class HashedPasswordTest {
 
   @Test
   public void encodeOneLine() throws Exception {
@@ -41,17 +40,9 @@
     assertThat(roundtrip.checkPassword("not the password")).isFalse();
   }
 
-  @Test(expected = DecoderException.class)
-  public void invalidDecode() throws Exception {
-    HashedPassword.decode("invalid");
-  }
-
   @Test
-  public void lengthLimit() throws Exception {
-    String password = Strings.repeat("1", 72);
-
-    // make sure it fits in varchar(255).
-    assertThat(HashedPassword.fromPassword(password).encode().length()).isLessThan(255);
+  public void invalidDecode() throws Exception {
+    assertThrows(DecoderException.class, () -> HashedPassword.decode("invalid"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/PreferencesTest.java b/javatests/com/google/gerrit/server/account/PreferencesTest.java
new file mode 100644
index 0000000..9866481
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/PreferencesTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gson.Gson;
+import org.junit.Test;
+
+public class PreferencesTest {
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
+  @Test
+  public void generalPreferencesRoundTrip() {
+    GeneralPreferencesInfo original = GeneralPreferencesInfo.defaults();
+    assertThat(GSON.toJson(original))
+        .isEqualTo(GSON.toJson(Preferences.General.fromInfo(original).toInfo()));
+  }
+
+  @Test
+  public void diffPreferencesRoundTrip() {
+    DiffPreferencesInfo original = DiffPreferencesInfo.defaults();
+    assertThat(GSON.toJson(original))
+        .isEqualTo(GSON.toJson(Preferences.Diff.fromInfo(original).toInfo()));
+  }
+
+  @Test
+  public void editPreferencesRoundTrip() {
+    EditPreferencesInfo original = EditPreferencesInfo.defaults();
+    assertThat(GSON.toJson(original))
+        .isEqualTo(GSON.toJson(Preferences.Edit.fromInfo(original).toInfo()));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/QueryListTest.java b/javatests/com/google/gerrit/server/account/QueryListTest.java
index a0876e1..7d491c9 100644
--- a/javatests/com/google/gerrit/server/account/QueryListTest.java
+++ b/javatests/com/google/gerrit/server/account/QueryListTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Test;
 
-public class QueryListTest extends GerritBaseTests {
+public class QueryListTest {
   public static final String Q_P = "project:foo";
   public static final String Q_B = "branch:bar";
   public static final String Q_COMPLEX = "branch:bar AND peers:'is:open\t'";
diff --git a/javatests/com/google/gerrit/server/account/StoredPreferencesTest.java b/javatests/com/google/gerrit/server/account/StoredPreferencesTest.java
new file mode 100644
index 0000000..c39e496
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/StoredPreferencesTest.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.server.git.ValidationError;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/** Tests for parsing user preferences from Git. */
+public class StoredPreferencesTest {
+
+  enum Unknown {
+    STATE
+  }
+
+  @Test
+  public void ignoreUnknownAccountPreferencesWhenParsing() {
+    ValidationError.Sink errorSink = Mockito.mock(ValidationError.Sink.class);
+    StoredPreferences preferences =
+        new StoredPreferences(Account.id(1), configWithUnknownEntries(), new Config(), errorSink);
+    GeneralPreferencesInfo parsedPreferences = preferences.getGeneralPreferences();
+
+    assertThat(parsedPreferences).isNotNull();
+    assertThat(parsedPreferences.expandInlineDiffs).isTrue();
+    verifyNoMoreInteractions(errorSink);
+  }
+
+  @Test
+  public void ignoreUnknownDefaultAccountPreferencesWhenParsing() {
+    ValidationError.Sink errorSink = Mockito.mock(ValidationError.Sink.class);
+    StoredPreferences preferences =
+        new StoredPreferences(Account.id(1), new Config(), configWithUnknownEntries(), errorSink);
+    GeneralPreferencesInfo parsedPreferences = preferences.getGeneralPreferences();
+
+    assertThat(parsedPreferences).isNotNull();
+    assertThat(parsedPreferences.expandInlineDiffs).isTrue();
+    verifyNoMoreInteractions(errorSink);
+  }
+
+  private static Config configWithUnknownEntries() {
+    Config cfg = new Config();
+    cfg.setBoolean("general", null, "expandInlineDiffs", true);
+    cfg.setBoolean("general", null, "unknown", true);
+    cfg.setEnum("general", null, "unknownenum", Unknown.STATE);
+    cfg.setString("general", null, "unknownstring", "bla");
+    return cfg;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 334c627..1e3063e 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -17,34 +17,32 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.getCurrentArguments;
-import static org.easymock.EasyMock.not;
-import static org.easymock.EasyMock.replay;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.AdditionalMatchers.not;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
 
-public class UniversalGroupBackendTest extends GerritBaseTests {
-  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
+public class UniversalGroupBackendTest {
+  private static final AccountGroup.UUID OTHER_UUID = AccountGroup.uuid("other");
 
   private UniversalGroupBackend backend;
   private IdentifiedUser user;
@@ -53,8 +51,7 @@
 
   @Before
   public void setup() {
-    user = createNiceMock(IdentifiedUser.class);
-    replay(user);
+    user = mock(IdentifiedUser.class);
     backends = new DynamicSet<>();
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
@@ -102,25 +99,26 @@
 
   @Test
   public void otherMemberships() {
-    final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
-    final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
-    final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
-    final IdentifiedUser notMember = createNiceMock(IdentifiedUser.class);
+    final AccountGroup.UUID handled = AccountGroup.uuid("handled");
+    final AccountGroup.UUID notHandled = AccountGroup.uuid("not handled");
+    final IdentifiedUser member = mock(IdentifiedUser.class);
+    final IdentifiedUser notMember = mock(IdentifiedUser.class);
 
-    GroupBackend backend = createMock(GroupBackend.class);
-    expect(backend.handles(handled)).andStubReturn(true);
-    expect(backend.handles(not(eq(handled)))).andStubReturn(false);
-    expect(backend.membershipsOf(anyObject(IdentifiedUser.class)))
-        .andStubAnswer(
-            () -> {
-              Object[] args = getCurrentArguments();
-              GroupMembership membership = createMock(GroupMembership.class);
-              expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
-              expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
-              replay(membership);
-              return membership;
+    GroupBackend backend = mock(GroupBackend.class);
+    when(backend.handles(eq(handled))).thenReturn(true);
+    when(backend.handles(not(eq(handled)))).thenReturn(false);
+    when(backend.membershipsOf(any(IdentifiedUser.class)))
+        .thenAnswer(
+            new Answer<GroupMembership>() {
+              @Override
+              public GroupMembership answer(InvocationOnMock invocation) {
+                GroupMembership membership = mock(GroupMembership.class);
+                when(membership.contains(eq(handled)))
+                    .thenReturn(invocation.getArguments()[0] == member);
+                when(membership.contains(eq(notHandled))).thenReturn(false);
+                return membership;
+              }
             });
-    replay(member, notMember, backend);
 
     backends = new DynamicSet<>();
     backends.add("gerrit", backend);
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 2ac7be7..95dbbde 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.NotifyValue;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
@@ -54,12 +55,12 @@
             + "  notify = [NEW_PATCHSETS]\n"
             + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
     Map<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
-        ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+        ProjectWatches.parse(Account.id(1000000), cfg, this);
 
     assertThat(validationErrors).isEmpty();
 
-    Project.NameKey myProject = new Project.NameKey("myProject");
-    Project.NameKey otherProject = new Project.NameKey("otherProject");
+    Project.NameKey myProject = Project.nameKey("myProject");
+    Project.NameKey otherProject = Project.nameKey("otherProject");
     Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches = new HashMap<>();
     expectedProjectWatches.put(
         ProjectWatchKey.create(myProject, null),
@@ -87,7 +88,7 @@
             + "[project \"otherProject\"]\n"
             + "  notify = [NEW_PATCHSETS]\n");
 
-    ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+    ProjectWatches.parse(Account.id(1000000), cfg, this);
     assertThat(validationErrors).hasSize(1);
     assertThat(validationErrors.get(0).getMessage())
         .isEqualTo(
@@ -170,14 +171,14 @@
   private void assertParseNotifyValueFails(String notifyValue) {
     assertThat(validationErrors).isEmpty();
     parseNotifyValue(notifyValue);
-    assertThat(validationErrors)
-        .named("expected validation error for notifyValue: " + notifyValue)
+    assertWithMessage("expected validation error for notifyValue: " + notifyValue)
+        .that(validationErrors)
         .isNotEmpty();
     validationErrors.clear();
   }
 
   private NotifyValue parseNotifyValue(String notifyValue) {
-    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue, this);
+    return NotifyValue.parse(Account.id(1000000), "project", notifyValue, this);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
index d757f71..45dacd9 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -21,17 +21,16 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import java.util.Arrays;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class AllExternalIdsTest extends GerritBaseTests {
+public class AllExternalIdsTest {
   @Test
   public void serializeEmptyExternalIds() throws Exception {
     assertRoundTrip(allExternalIds(), AllExternalIdsProto.getDefaultInstance());
@@ -39,8 +38,8 @@
 
   @Test
   public void serializeMultipleExternalIds() throws Exception {
-    Account.Id accountId1 = new Account.Id(1001);
-    Account.Id accountId2 = new Account.Id(1002);
+    Account.Id accountId1 = Account.id(1001);
+    Account.Id accountId2 = Account.id(1002);
     assertRoundTrip(
         allExternalIds(
             ExternalId.create("scheme1", "id1", accountId1),
@@ -62,7 +61,7 @@
   @Test
   public void serializeExternalIdWithEmail() throws Exception {
     assertRoundTrip(
-        allExternalIds(ExternalId.createEmail(new Account.Id(1001), "foo@example.com")),
+        allExternalIds(ExternalId.createEmail(Account.id(1001), "foo@example.com")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -76,7 +75,7 @@
   public void serializeExternalIdWithPassword() throws Exception {
     assertRoundTrip(
         allExternalIds(
-            ExternalId.create("scheme", "id", new Account.Id(1001), null, "hashed password")),
+            ExternalId.create("scheme", "id", Account.id(1001), null, "hashed password")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -91,7 +90,7 @@
     assertRoundTrip(
         allExternalIds(
             ExternalId.create(
-                ExternalId.create("scheme", "id", new Account.Id(1001)),
+                ExternalId.create("scheme", "id", Account.id(1001)),
                 ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
new file mode 100644
index 0000000..054b1aa
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ExternalIDCacheLoaderTest {
+  private static AllUsersName ALL_USERS = new AllUsersName(AllUsersNameProvider.DEFAULT);
+
+  @Mock Cache<ObjectId, AllExternalIds> externalIdCache;
+
+  private ExternalIdCacheLoader loader;
+  private GitRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private ExternalIdReader externalIdReader;
+  private ExternalIdReader externalIdReaderSpy;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager.createRepository(ALL_USERS).close();
+    externalIdReader = new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker());
+    externalIdReaderSpy = Mockito.spy(externalIdReader);
+    loader = createLoader(true);
+  }
+
+  @Test
+  public void worksOnSingleCommit() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    assertThat(loader.load(firstState)).isEqualTo(allFromGit(firstState));
+    verify(externalIdReaderSpy, times(1)).all(firstState);
+  }
+
+  @Test
+  public void reloadsSingleUpdateUsingPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void reloadsMultipleUpdatesUsingPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    insertExternalId(2, 2);
+    insertExternalId(3, 3);
+    ObjectId head = insertExternalId(4, 4);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void reloadsAllExternalIdsWhenNoOldStateIsCached() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(null);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void partialReloadingDisabledAlwaysTriggersFullReload() throws Exception {
+    loader = createLoader(false);
+    insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void fallsBackToFullReloadOnManyUpdatesOnBranch() throws Exception {
+    insertExternalId(1, 1);
+    ObjectId head = null;
+    for (int i = 2; i < 20; i++) {
+      head = insertExternalId(i, i);
+    }
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void doesFullReloadWhenNoCacheStateIsFound() throws Exception {
+    ObjectId head = insertExternalId(1, 1);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void handlesDeletionInPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = deleteExternalId(1, 1);
+    assertThat(allFromGit(head).byAccount().size()).isEqualTo(0);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesModifyInPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head =
+        modifyExternalId(
+            externalId(1, 1),
+            ExternalId.create("fooschema", "bar1", Account.id(1), "foo@bar.com", "password"));
+    assertThat(allFromGit(head).byAccount().size()).isEqualTo(1);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void ignoresInvalidExternalId() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head;
+    try (Repository repo = repoManager.openRepository(ALL_USERS);
+        RevWalk rw = new RevWalk(repo)) {
+      ExternalIdTestUtil.insertExternalIdWithKeyThatDoesntMatchNoteId(
+          repo, rw, new PersonIdent("foo", "foo@bar.com"), Account.id(2), "test");
+      head = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+    }
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesTreePrefixesInDifferentialReload() throws Exception {
+    // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
+    // created a situation where NoteNames are sharded.
+    ObjectId oldState = inserExternalIds(257);
+    assertAllFilesHaveSlashesInPath();
+    ObjectId head = insertExternalId(500, 500);
+
+    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesReshard() throws Exception {
+    // Create 256 notes (NoteMap's current sharding limit) and check that we are not yet sharding
+    ObjectId oldState = inserExternalIds(256);
+    assertNoFilesHaveSlashesInPath();
+    // Create one more external ID and then have the Loader compute the new state
+    ObjectId head = insertExternalId(500, 500);
+    assertAllFilesHaveSlashesInPath(); // NoteMap resharded
+
+    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  private ExternalIdCacheLoader createLoader(boolean allowPartial) {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", allowPartial);
+    return new ExternalIdCacheLoader(
+        repoManager,
+        ALL_USERS,
+        externalIdReaderSpy,
+        Providers.of(externalIdCache),
+        new DisabledMetricMaker(),
+        cfg);
+  }
+
+  private AllExternalIds allFromGit(ObjectId revision) throws Exception {
+    return AllExternalIds.create(externalIdReader.all(revision));
+  }
+
+  private ObjectId inserExternalIds(int numberOfIdsToInsert) throws Exception {
+    ObjectId oldState = null;
+    // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
+    // created a situation where NoteNames are sharded.
+    for (int i = 0; i < numberOfIdsToInsert; i++) {
+      oldState = insertExternalId(i, i);
+    }
+    return oldState;
+  }
+
+  private ObjectId insertExternalId(int key, int accountId) throws Exception {
+    return performExternalIdUpdate(
+        u -> {
+          try {
+            u.insert(externalId(key, accountId));
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  private ObjectId modifyExternalId(ExternalId oldId, ExternalId newId) throws Exception {
+    return performExternalIdUpdate(
+        u -> {
+          try {
+            u.replace(oldId, newId);
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  private ObjectId deleteExternalId(int key, int accountId) throws Exception {
+    return performExternalIdUpdate(u -> u.delete(externalId(key, accountId)));
+  }
+
+  private ExternalId externalId(int key, int accountId) {
+    return ExternalId.create("fooschema", "bar" + key, Account.id(accountId));
+  }
+
+  private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
+    try (Repository repo = repoManager.openRepository(ALL_USERS)) {
+      PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo);
+      update.accept(extIdNotes);
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(updater);
+        metaDataUpdate.getCommitBuilder().setCommitter(updater);
+        return extIdNotes.commit(metaDataUpdate).getId();
+      }
+    }
+  }
+
+  private void assertAllFilesHaveSlashesInPath() throws Exception {
+    assertThat(allFilesInExternalIdRef().stream().allMatch(f -> f.contains("/"))).isTrue();
+  }
+
+  private void assertNoFilesHaveSlashesInPath() throws Exception {
+    assertThat(allFilesInExternalIdRef().stream().noneMatch(f -> f.contains("/"))).isTrue();
+  }
+
+  private ImmutableList<String> allFilesInExternalIdRef() throws Exception {
+    try (Repository repo = repoManager.openRepository(ALL_USERS);
+        TreeWalk treeWalk = new TreeWalk(repo);
+        RevWalk rw = new RevWalk(repo)) {
+      treeWalk.reset(
+          rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId()).getTree());
+      treeWalk.setRecursive(true);
+      ImmutableList.Builder<String> allPaths = ImmutableList.builder();
+      while (treeWalk.next()) {
+        allPaths.add(treeWalk.getPathString());
+      }
+      return allPaths.build();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
index 13de3e7..ba40d8c 100644
--- a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
@@ -24,8 +24,8 @@
 import static com.google.gerrit.server.auth.ldap.LdapModule.USERNAME_CACHE;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.testing.InMemoryModule;
@@ -64,7 +64,7 @@
   }
 
   private ExternalId id(String scheme, String id) {
-    return ExternalId.create(scheme, id, new Account.Id(1000));
+    return ExternalId.create(scheme, id, Account.id(1000));
   }
 
   private boolean accountBelongsToRealm(ExternalId... ids) {
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
index 7a0661a..dc62a61 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -39,7 +39,7 @@
   }
 
   private ExternalId id(String scheme, String id) {
-    return ExternalId.create(scheme, id, new Account.Id(1000));
+    return ExternalId.create(scheme, id, Account.id(1000));
   }
 
   private boolean accountBelongsToRealm(ExternalId... ids) {
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 6a42577..d19073d 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.function.Supplier;
 import org.junit.Test;
 
-public class PerThreadCacheTest extends GerritBaseTests {
+public class PerThreadCacheTest {
   @Test
   public void key_respectsClass() {
     assertThat(PerThreadCache.Key.create(String.class))
@@ -75,9 +75,9 @@
   @Test
   public void doubleInstantiationFails() {
     try (PerThreadCache ignored = PerThreadCache.create()) {
-      exception.expect(IllegalStateException.class);
-      exception.expectMessage("called create() twice on the same request");
-      PerThreadCache.create();
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 147aeeb..69c2799 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.cache.h2;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -66,22 +67,22 @@
                   return "bar";
                 }))
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isTrue();
-    assertThat(impl.getIfPresent("foo")).named("in-memory value").isEqualTo("bar");
+    assertWithMessage("Callable was called").that(called.get()).isTrue();
+    assertWithMessage("in-memory value").that(impl.getIfPresent("foo")).isEqualTo("bar");
     mem.invalidate("foo");
-    assertThat(impl.getIfPresent("foo")).named("persistent value").isEqualTo("bar");
+    assertWithMessage("persistent value").that(impl.getIfPresent("foo")).isEqualTo("bar");
 
     called.set(false);
-    assertThat(
+    assertWithMessage("cached value")
+        .that(
             impl.get(
                 "foo",
                 () -> {
                   called.set(true);
                   return "baz";
                 }))
-        .named("cached value")
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isFalse();
+    assertWithMessage("Callable was called").that(called.get()).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index a0d5ea6..ce5f273 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -4,16 +4,16 @@
     name = "tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//java/com/google/gwtorm",
         "//lib:guava",
+        "//lib:jgit",
         "//lib:junit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index c634a78..ebd7d55 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.protobuf.TextFormat;
 import org.junit.Test;
 
-public class BooleanCacheSerializerTest extends GerritBaseTests {
+public class BooleanCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
@@ -53,11 +51,6 @@
   }
 
   private static void assertDeserializeFails(byte[] in) {
-    try {
-      BooleanCacheSerializer.INSTANCE.deserialize(in);
-      assert_().fail("expected deserialization to fail for \"%s\"", TextFormat.escapeBytes(in));
-    } catch (RuntimeException e) {
-      // Expected.
-    }
+    assertThrows(RuntimeException.class, () -> BooleanCacheSerializer.INSTANCE.deserialize(in));
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java
new file mode 100644
index 0000000..819189f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import org.junit.Test;
+
+public class CacheSerializerTest {
+  @AutoValue
+  abstract static class MyAutoValue {
+    static MyAutoValue create(int val) {
+      return new AutoValue_CacheSerializerTest_MyAutoValue(val);
+    }
+
+    abstract int val();
+  }
+
+  private static final CacheSerializer<MyAutoValue> SERIALIZER =
+      CacheSerializer.convert(
+          IntegerCacheSerializer.INSTANCE, Converter.from(MyAutoValue::val, MyAutoValue::create));
+
+  @Test
+  public void serialize() throws Exception {
+    MyAutoValue v = MyAutoValue.create(1234);
+    byte[] serialized = SERIALIZER.serialize(v);
+    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
+    assertThat(SERIALIZER.deserialize(serialized).val()).isEqualTo(1234);
+  }
+
+  @Test
+  public void deserializeNullFails() throws Exception {
+    assertThrows(RuntimeException.class, () -> SERIALIZER.deserialize(null));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
index c6efc21..7bfcc59 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EnumCacheSerializerTest extends GerritBaseTests {
+public class EnumCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     assertRoundTrip(MyEnum.FOO);
@@ -50,11 +49,6 @@
 
   private static void assertDeserializeFails(byte[] in) {
     CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
-    try {
-      s.deserialize(in);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
+    assertThrows(RuntimeException.class, () -> s.deserialize(in));
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
deleted file mode 100644
index 56dd6ad..0000000
--- a/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache.serialize;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.Key;
-import org.junit.Test;
-
-public class IntKeyCacheSerializerTest extends GerritBaseTests {
-
-  private static class MyIntKey extends IntKey<Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    private int val;
-
-    MyIntKey(int val) {
-      this.val = val;
-    }
-
-    @Override
-    public int get() {
-      return val;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      this.val = newValue;
-    }
-  }
-
-  private static final IntKeyCacheSerializer<MyIntKey> SERIALIZER =
-      new IntKeyCacheSerializer<>(MyIntKey::new);
-
-  @Test
-  public void serialize() throws Exception {
-    MyIntKey k = new MyIntKey(1234);
-    byte[] serialized = SERIALIZER.serialize(k);
-    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
-    assertThat(SERIALIZER.deserialize(serialized).get()).isEqualTo(1234);
-  }
-
-  @Test
-  public void deserializeNullFails() throws Exception {
-    try {
-      SERIALIZER.deserialize(null);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
index 1d54010..40ff0ac 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.cache.serialize;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Bytes;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.TextFormat;
 import org.junit.Test;
 
-public class IntegerCacheSerializerTest extends GerritBaseTests {
+public class IntegerCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     for (int i :
@@ -49,17 +48,12 @@
   private static void assertRoundTrip(int i) throws Exception {
     byte[] serialized = IntegerCacheSerializer.INSTANCE.serialize(i);
     int result = IntegerCacheSerializer.INSTANCE.deserialize(serialized);
-    assertThat(result)
-        .named("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+    assertWithMessage("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+        .that(result)
         .isEqualTo(i);
   }
 
   private static void assertDeserializeFails(byte[] in) {
-    try {
-      IntegerCacheSerializer.INSTANCE.deserialize(in);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
+    assertThrows(RuntimeException.class, () -> IntegerCacheSerializer.INSTANCE.deserialize(in));
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index 9fcb8a4..effc801 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -17,11 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Project;
 import java.io.Serializable;
 import org.junit.Test;
 
-public class JavaCacheSerializerTest extends GerritBaseTests {
+public class JavaCacheSerializerTest {
   @Test
   public void builtInTypes() throws Exception {
     assertRoundTrip("foo");
@@ -34,6 +34,11 @@
     assertRoundTrip(new AutoValue_JavaCacheSerializerTest_MyType(123, "four five six"));
   }
 
+  @Test
+  public void gerritEntities() throws Exception {
+    assertRoundTrip(Project.nameKey("foo"));
+  }
+
   @AutoValue
   abstract static class MyType implements Serializable {
     private static final long serialVersionUID = 1L;
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
index 257be54..7d6647a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ObjectIdCacheSerializerTest extends GerritBaseTests {
+public class ObjectIdCacheSerializerTest {
   @Test
   public void serialize() {
     ObjectId id = ObjectId.fromString("aabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
@@ -47,11 +46,7 @@
   }
 
   private void assertDeserializeFails(byte[] bytes) {
-    try {
-      ObjectIdCacheSerializer.INSTANCE.deserialize(bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class, () -> ObjectIdCacheSerializer.INSTANCE.deserialize(bytes));
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
index c5ea2ea..f6d6c8a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ObjectIdConverterTest extends GerritBaseTests {
+public class ObjectIdConverterTest {
   @Test
   public void objectIdFromByteString() {
     ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -43,12 +42,9 @@
 
   @Test
   public void objectIdFromByteStringWrongSize() {
-    try {
-      ObjectIdConverter.create().fromByteString(ByteString.copyFromUtf8("foo"));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> ObjectIdConverter.create().fromByteString(ByteString.copyFromUtf8("foo")));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
index 845da9b..04d2f73 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.proto.testing.Test.SerializableProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ProtobufSerializerTest extends GerritBaseTests {
+public class ProtobufSerializerTest {
   @Test
   public void requiredAndOptionalTypes() {
     assertRoundTrip(SerializableProto.newBuilder().setId(123));
diff --git a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
index ff0cf9a..dc22805 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.charset.CharacterCodingException;
 import java.nio.charset.StandardCharsets;
 import org.junit.Test;
 
-public class StringCacheSerializerTest extends GerritBaseTests {
+public class StringCacheSerializerTest {
   @Test
   public void serialize() {
     assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
@@ -35,12 +34,11 @@
   @Test
   public void serializeInvalidChar() {
     // Can't use UTF-8 for the test, since it can encode all Unicode code points.
-    try {
-      StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
-      assert_().fail("expected IllegalStateException");
-    } catch (IllegalStateException expected) {
-      assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
-    }
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234"));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CharacterCodingException.class);
   }
 
   @Test
@@ -56,11 +54,10 @@
 
   @Test
   public void deserializeInvalidChar() {
-    try {
-      StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
-      assert_().fail("expected IllegalStateException");
-    } catch (IllegalStateException expected) {
-      assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
-    }
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff}));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CharacterCodingException.class);
   }
 }
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index fffb1da..20813f6 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -24,11 +24,10 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ChangeKindCacheImplTest extends GerritBaseTests {
+public class ChangeKindCacheImplTest {
   @Test
   public void keySerializer() throws Exception {
     ChangeKindCacheImpl.Key key =
diff --git a/javatests/com/google/gerrit/server/change/HashtagsTest.java b/javatests/com/google/gerrit/server/change/HashtagsTest.java
index 49d2952..780ac71 100644
--- a/javatests/com/google/gerrit/server/change/HashtagsTest.java
+++ b/javatests/com/google/gerrit/server/change/HashtagsTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HashtagsTest extends GerritBaseTests {
+public class HashtagsTest {
   @Test
   public void emptyCommitMessage() throws Exception {
     assertThat(HashtagsUtil.extractTags("")).isEmpty();
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
index 0cfe483..19c479d 100644
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+import static com.google.gerrit.entities.RefNames.REFS_TAGS;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -27,7 +26,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class IncludedInResolverTest extends GerritBaseTests {
+public class IncludedInResolverTest {
   // Branch names
   private static final String BRANCH_MASTER = "master";
   private static final String BRANCH_1_0 = "rel-1.0";
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 6e02d61..c259e60 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -14,24 +14,25 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.common.data.Permission.forLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -57,7 +57,7 @@
 import org.junit.Test;
 
 /** Unit tests for {@link LabelNormalizer}. */
-public class LabelNormalizerTest extends GerritBaseTests {
+public class LabelNormalizerTest {
   @Inject private AccountManager accountManager;
   @Inject private AllProjectsName allProjects;
   @Inject private GitRepositoryManager repoManager;
@@ -70,6 +70,7 @@
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private ProjectConfig.Factory projectConfigFactory;
   @Inject private GerritApi gApi;
+  @Inject private ProjectOperations projectOperations;
 
   private LifecycleManager lifecycle;
   private Account.Id userId;
@@ -103,7 +104,7 @@
       }
     }
     LabelType lt =
-        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
     pc.getLabelSections().put(lt.getName(), lt);
     save(pc);
   }
@@ -115,7 +116,7 @@
     input.newBranch = true;
     input.subject = "Test change";
     ChangeInfo info = gApi.changes().create(input).get();
-    notes = changeNotesFactory.createChecked(allProjects, new Change.Id(info._number));
+    notes = changeNotesFactory.createChecked(allProjects, Change.id(info._number));
     change = notes.getChange();
   }
 
@@ -129,10 +130,11 @@
 
   @Test
   public void noNormalizeByPermission() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 2);
     PatchSetApproval v = psa(userId, "Verified", 1);
@@ -141,10 +143,11 @@
 
   @Test
   public void normalizeByType() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 5);
     PatchSetApproval v = psa(userId, "Verified", 5);
@@ -162,9 +165,10 @@
 
   @Test
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 0);
     PatchSetApproval v = psa(userId, "Verified", 0);
@@ -187,16 +191,15 @@
   }
 
   private PatchSetApproval psa(Account.Id accountId, String label, int value) {
-    return new PatchSetApproval(
-        new PatchSetApproval.Key(change.currentPatchSetId(), accountId, new LabelId(label)),
-        (short) value,
-        TimeUtil.nowTs());
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.nowTs())
+        .build();
   }
 
   private PatchSetApproval copy(PatchSetApproval src, int newValue) {
-    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
-    result.setValue((short) newValue);
-    return result;
+    return src.toBuilder().value(newValue).build();
   }
 
   private static List<PatchSetApproval> list(PatchSetApproval... psas) {
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index 46ddbc2..19c8998 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -23,11 +23,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class MergeabilityCacheImplTest extends GerritBaseTests {
+public class MergeabilityCacheImplTest {
   @Test
   public void keySerializer() throws Exception {
     MergeabilityCacheImpl.EntryKey key =
diff --git a/javatests/com/google/gerrit/server/change/WalkSorterTest.java b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
index 189dfbc..2c4c98f 100644
--- a/javatests/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
@@ -19,14 +19,12 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testing.TestChanges;
@@ -39,13 +37,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class WalkSorterTest extends GerritBaseTests {
+public class WalkSorterTest {
   private Account.Id userId;
   private InMemoryRepositoryManager repoManager;
 
   @Before
   public void setUp() {
-    userId = new Account.Id(1);
+    userId = Account.id(1);
     repoManager = new InMemoryRepositoryManager();
   }
 
@@ -280,7 +278,7 @@
 
     // If we restrict to PS1 of each change, the sorter uses that commit.
     sorter.includePatchSets(
-        ImmutableSet.of(new PatchSet.Id(cd1.getId(), 1), new PatchSet.Id(cd2.getId(), 1)));
+        ImmutableSet.of(PatchSet.id(cd1.getId(), 1), PatchSet.id(cd2.getId(), 1)));
     assertSorted(
         sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
   }
@@ -297,8 +295,7 @@
 
     List<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter =
-        new WalkSorter(repoManager)
-            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+        new WalkSorter(repoManager).includePatchSets(ImmutableSet.of(cd1.currentPatchSet().id()));
 
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
   }
@@ -335,17 +332,15 @@
   private ChangeData newChange(TestRepository<Repo> tr, ObjectId id) throws Exception {
     Project.NameKey project = tr.getRepository().getDescription().getProject();
     Change c = TestChanges.newChange(project, userId);
-    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
+    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1, id);
     cd.setChange(c);
-    cd.currentPatchSet().setRevision(new RevId(id.name()));
     cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
     return cd;
   }
 
   private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
     TestChanges.incrementPatchSet(cd.change());
-    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
-    ps.setRevision(new RevId(id.name()));
+    PatchSet ps = TestChanges.newPatchSet(cd.change().currentPatchSetId(), id.name(), userId);
     List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
     patchSets.add(ps);
     cd.setPatchSets(patchSets);
@@ -353,7 +348,7 @@
   }
 
   private TestRepository<Repo> newRepo(String name) throws Exception {
-    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.createRepository(Project.nameKey(name)));
   }
 
   private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
@@ -362,7 +357,7 @@
 
   private static PatchSetData patchSetData(ChangeData cd, int psId, RevCommit commit)
       throws Exception {
-    return PatchSetData.create(cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
+    return PatchSetData.create(cd, cd.patchSet(PatchSet.id(cd.getId(), psId)), commit);
   }
 
   private static void assertSorted(
diff --git a/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
new file mode 100644
index 0000000..64a5f83
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.junit.Test;
+
+public class AllProjectsNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allProjectsName.get()).isEqualTo(projectName.get());
+    assertThat(allProjectsName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllUsersName() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    AllUsersName allUsersName = new AllUsersName(name);
+    assertThat(allProjectsName.get()).isEqualTo(allUsersName.get());
+    assertThat(allProjectsName).isEqualTo(allUsersName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/AllUsersNameTest.java b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
new file mode 100644
index 0000000..d46b7a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.junit.Test;
+
+public class AllUsersNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allUsersName.get()).isEqualTo(projectName.get());
+    assertThat(allUsersName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllProjectsName() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    assertThat(allUsersName.get()).isEqualTo(allProjectsName.get());
+    assertThat(allUsersName).isEqualTo(allProjectsName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
index b1378ad..035878c 100644
--- a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class ConfigUtilTest extends GerritBaseTests {
+public class ConfigUtilTest {
   private static final String SECT = "foo";
   private static final String SUB = "bar";
 
@@ -85,17 +85,17 @@
     Config cfg = new Config();
     ConfigUtil.storeSection(cfg, SECT, SUB, in, d);
 
-    assertThat(cfg.getString(SECT, SUB, "CONSTANT")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "missing")).isNull();
-    assertThat(cfg.getBoolean(SECT, SUB, "b", false)).isEqualTo(in.b);
-    assertThat(cfg.getBoolean(SECT, SUB, "bb", false)).isEqualTo(in.bb);
-    assertThat(cfg.getInt(SECT, SUB, "i", 0)).isEqualTo(0);
-    assertThat(cfg.getInt(SECT, SUB, "ii", 0)).isEqualTo(in.ii);
-    assertThat(cfg.getLong(SECT, SUB, "l", 0L)).isEqualTo(0L);
-    assertThat(cfg.getLong(SECT, SUB, "ll", 0L)).isEqualTo(in.ll);
-    assertThat(cfg.getString(SECT, SUB, "s")).isEqualTo(in.s);
-    assertThat(cfg.getString(SECT, SUB, "sd")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "nd")).isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "CONSTANT").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "missing").isNull();
+    assertThat(cfg).booleanValue(SECT, SUB, "b", false).isEqualTo(in.b);
+    assertThat(cfg).booleanValue(SECT, SUB, "bb", false).isEqualTo(in.bb);
+    assertThat(cfg).intValue(SECT, SUB, "i", 0).isEqualTo(0);
+    assertThat(cfg).intValue(SECT, SUB, "ii", 0).isEqualTo(in.ii);
+    assertThat(cfg).longValue(SECT, SUB, "l", 0L).isEqualTo(0L);
+    assertThat(cfg).longValue(SECT, SUB, "ll", 0L).isEqualTo(in.ll);
+    assertThat(cfg).stringValue(SECT, SUB, "s").isEqualTo(in.s);
+    assertThat(cfg).stringValue(SECT, SUB, "sd").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "nd").isNull();
 
     SectionInfo out = new SectionInfo();
     ConfigUtil.loadSection(cfg, SECT, SUB, out, d, null);
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index bf7e4fd..cb6de34 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class GitwebConfigTest extends GerritBaseTests {
+public class GitwebConfigTest {
   private static final String VALID_CHARACTERS = "*()";
   private static final String SOME_INVALID_CHARACTERS = "09AZaz$-_.+!',";
 
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 30fabdc..708e247 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -18,16 +18,15 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
 import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -36,7 +35,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ListCapabilitiesTest extends GerritBaseTests {
+public class ListCapabilitiesTest {
   private Injector injector;
 
   @Before
@@ -74,7 +73,7 @@
   @Test
   public void list() throws Exception {
     Map<String, CapabilityInfo> m =
-        injector.getInstance(ListCapabilities.class).apply(new ConfigResource());
+        injector.getInstance(ListCapabilities.class).apply(new ConfigResource()).value();
     for (String id : GlobalCapability.getAllNames()) {
       assertThat(m).containsKey(id);
       assertThat(m.get(id).id).isEqualTo(id);
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index 2a473f4..d7aae6a0 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -18,9 +18,8 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -28,7 +27,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RepositoryConfigTest extends GerritBaseTests {
+public class RepositoryConfigTest {
 
   private Config cfg;
   private RepositoryConfig repoCfg;
@@ -42,35 +41,35 @@
   @Test
   public void defaultSubmitTypeWhenNotConfigured() {
     // Check expected value explicitly rather than depending on constant.
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.INHERIT);
   }
 
   @Test
   public void defaultSubmitTypeForStarFilter() {
     configureDefaultSubmitType("*", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
     configureDefaultSubmitType("*", SubmitType.FAST_FORWARD_ONLY);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
   public void defaultSubmitTypeForSpecificFilter() {
     configureDefaultSubmitType("someProject", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someOtherProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someOtherProject")))
         .isEqualTo(RepositoryConfig.DEFAULT_SUBMIT_TYPE);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
   }
 
@@ -80,13 +79,13 @@
     configureDefaultSubmitType("somePath/*", SubmitType.CHERRY_PICK);
     configureDefaultSubmitType("*", SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/somePath/someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
   }
 
@@ -100,14 +99,14 @@
 
   @Test
   public void ownerGroupsWhenNotConfigured() {
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject"))).isEmpty();
   }
 
   @Test
   public void ownerGroupsForStarFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("*", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -115,8 +114,8 @@
   public void ownerGroupsForSpecificFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("someProject", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someOtherProject"))).isEmpty();
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someOtherProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -130,13 +129,13 @@
     configureOwnerGroups("somePath/*", ownerGroups2);
     configureOwnerGroups("somePath/somePath/*", ownerGroups3);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups1);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups2);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups3);
   }
 
@@ -150,24 +149,22 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
   }
 
   @Test
   public void basePathForStarFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("*", basePath);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someOtherProject"))).isNull();
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
@@ -182,14 +179,12 @@
     configureBasePath("project/*", basePath3);
     configureBasePath("*", basePath4);
 
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project1")).toString())
-        .isEqualTo(basePath1);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project/project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project1")).toString()).isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/project/someProject")).toString())
         .isEqualTo(basePath2);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/someProject")).toString())
         .isEqualTo(basePath3);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath4);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath4);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 6926052..55f0374 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -22,7 +22,6 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
@@ -32,7 +31,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class ScheduleConfigTest extends GerritBaseTests {
+public class ScheduleConfigTest {
 
   // Friday June 13, 2014 10:00 UTC
   private static final ZonedDateTime NOW =
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index b4cde14..1e5f41d 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.server.ioutil.HostPlatform;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.NotDirectoryException;
@@ -26,7 +26,7 @@
 import java.nio.file.Paths;
 import org.junit.Test;
 
-public class SitePathsTest extends GerritBaseTests {
+public class SitePathsTest {
   @Test
   public void create_NotExisting() throws IOException {
     final Path root = random();
@@ -72,8 +72,8 @@
     final Path root = random();
     try {
       Files.createFile(root);
-      exception.expect(NotDirectoryException.class);
-      new SitePaths(root);
+      assertThrows(NotDirectoryException.class, () -> new SitePaths(root));
+
     } finally {
       Files.delete(root);
     }
diff --git a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
index 4c0b5a1..2bad815 100644
--- a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
+++ b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
@@ -16,19 +16,18 @@
 
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import org.junit.Test;
 
-public class ChangeEditTest extends GerritBaseTests {
+public class ChangeEditTest {
   @Test
   public void changeEditRef() throws Exception {
-    Account.Id accountId = new Account.Id(1000042);
-    Change.Id changeId = new Change.Id(56414);
-    PatchSet.Id psId = new PatchSet.Id(changeId, 50);
+    Account.Id accountId = Account.id(1000042);
+    Change.Id changeId = Change.id(56414);
+    PatchSet.Id psId = PatchSet.id(changeId, 50);
     String refName = RefNames.refsEdit(accountId, changeId, psId);
     assertEquals("refs/users/42/1000042/edit-56414/50", refName);
   }
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
index 265b24e..a618c9e 100644
--- a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -25,31 +25,38 @@
 import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 
-public class ChangeFileContentModificationSubject
-    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+public class ChangeFileContentModificationSubject extends Subject {
 
   public static ChangeFileContentModificationSubject assertThat(
       ChangeFileContentModification modification) {
-    return assertAbout(ChangeFileContentModificationSubject::new).that(modification);
+    return assertAbout(modifications()).that(modification);
   }
 
+  public static Factory<ChangeFileContentModificationSubject, ChangeFileContentModification>
+      modifications() {
+    return ChangeFileContentModificationSubject::new;
+  }
+
+  private final ChangeFileContentModification modification;
+
   private ChangeFileContentModificationSubject(
       FailureMetadata failureMetadata, ChangeFileContentModification modification) {
     super(failureMetadata, modification);
+    this.modification = modification;
   }
 
   public StringSubject filePath() {
     isNotNull();
-    return check("filePath()").that(actual().getFilePath());
+    return check("getFilePath()").that(modification.getFilePath());
   }
 
   public StringSubject newContent() throws IOException {
     isNotNull();
-    RawInput newContent = actual().getNewContent();
-    check("newContent()").that(newContent).isNotNull();
+    RawInput newContent = modification.getNewContent();
+    check("getNewContent()").that(newContent).isNotNull();
     String contentString =
         CharStreams.toString(
             new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
-    return check("newContent()").that(contentString);
+    return check("getNewContent()").that(contentString);
   }
 }
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
index bd9d4df..d5b70bb 100644
--- a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.truth.ListSubject;
 import java.util.List;
 
-public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+public class TreeModificationSubject extends Subject {
 
   public static TreeModificationSubject assertThat(TreeModification treeModification) {
     return assertAbout(treeModifications()).that(treeModification);
@@ -33,19 +33,21 @@
 
   public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
       List<TreeModification> treeModifications) {
-    return assertAbout(ListSubject.elements())
-        .thatCustom(treeModifications, treeModifications())
-        .named("treeModifications");
+    return ListSubject.assertThat(treeModifications, treeModifications());
   }
 
+  private final TreeModification treeModification;
+
   private TreeModificationSubject(
       FailureMetadata failureMetadata, TreeModification treeModification) {
     super(failureMetadata, treeModification);
+    this.treeModification = treeModification;
   }
 
   public ChangeFileContentModificationSubject asChangeFileContentModification() {
     isInstanceOf(ChangeFileContentModification.class);
-    return ChangeFileContentModificationSubject.assertThat(
-        (ChangeFileContentModification) actual());
+    return check("asChangeFileContentModification()")
+        .about(ChangeFileContentModificationSubject.modifications())
+        .that((ChangeFileContentModification) treeModification);
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/BUILD b/javatests/com/google/gerrit/server/events/BUILD
new file mode 100644
index 0000000..eed83c8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/events/BUILD
@@ -0,0 +1,15 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "events_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index eac8d0d..e0223e4 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,38 +18,32 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
+import java.sql.Timestamp;
 import org.junit.Test;
 
-public class EventDeserializerTest extends GerritBaseTests {
+public class EventDeserializerTest {
+  private final Gson gson = new EventGsonProvider().get();
 
   @Test
   public void refUpdatedEvent() {
-    RefUpdatedEvent refUpdatedEvent = new RefUpdatedEvent();
-
+    RefUpdatedEvent orig = new RefUpdatedEvent();
     RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
     refUpdatedAttribute.refName = "refs/heads/master";
-    refUpdatedEvent.refUpdate = createSupplier(refUpdatedAttribute);
+    orig.refUpdate = createSupplier(refUpdatedAttribute);
 
     AccountAttribute accountAttribute = new AccountAttribute();
     accountAttribute.email = "some.user@domain.com";
-    refUpdatedEvent.submitter = createSupplier(accountAttribute);
+    orig.submitter = createSupplier(accountAttribute);
 
-    Gson gsonSerializer =
-        new GsonBuilder().registerTypeAdapter(Supplier.class, new SupplierSerializer()).create();
-    String serializedEvent = gsonSerializer.toJson(refUpdatedEvent);
-
-    Gson gsonDeserializer =
-        new GsonBuilder()
-            .registerTypeAdapter(Event.class, new EventDeserializer())
-            .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-            .create();
-
-    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer.fromJson(serializedEvent, Event.class);
+    RefUpdatedEvent e = roundTrip(orig);
 
     assertThat(e).isNotNull();
     assertThat(e.refUpdate).isInstanceOf(Supplier.class);
@@ -58,7 +52,271 @@
     assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
   }
 
+  @Test
+  public void patchSetCreatedEvent() {
+    Change change = newChange();
+    PatchSetCreatedEvent orig = new PatchSetCreatedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.uploader = newAccount("uploader");
+
+    PatchSetCreatedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.uploader, orig.uploader);
+  }
+
+  @Test
+  public void assigneeChangedEvent() {
+    Change change = newChange();
+    AssigneeChangedEvent orig = new AssigneeChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.changer = newAccount("changer");
+    orig.oldAssignee = newAccount("oldAssignee");
+
+    AssigneeChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.changer, orig.changer);
+    assertSameAccount(e.oldAssignee, orig.oldAssignee);
+  }
+
+  @Test
+  public void changeDeletedEvent() {
+    Change change = newChange();
+    ChangeDeletedEvent orig = new ChangeDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.deleter = newAccount("deleter");
+
+    ChangeDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.deleter, orig.deleter);
+  }
+
+  @Test
+  public void hashtagsChangedEvent() {
+    Change change = newChange();
+    HashtagsChangedEvent orig = new HashtagsChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.editor = newAccount("editor");
+    orig.added = new String[] {"added"};
+    orig.removed = new String[] {"removed"};
+    orig.hashtags = new String[] {"hashtags"};
+
+    HashtagsChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.editor, orig.editor);
+    assertThat(e.added).isEqualTo(orig.added);
+    assertThat(e.removed).isEqualTo(orig.removed);
+    assertThat(e.hashtags).isEqualTo(orig.hashtags);
+  }
+
+  @Test
+  public void changeAbandonedEvent() {
+    Change change = newChange();
+    ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.abandoner = newAccount("abandoner");
+    orig.reason = "some reason";
+
+    ChangeAbandonedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.abandoner, orig.abandoner);
+    assertThat(e.reason).isEqualTo(orig.reason);
+  }
+
+  @Test
+  public void changeMergedEvent() {
+    Change change = newChange();
+    ChangeMergedEvent orig = new ChangeMergedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeMergedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void changeRestoredEvent() {
+    Change change = newChange();
+    ChangeRestoredEvent orig = new ChangeRestoredEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeRestoredEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void commentAddedEvent() {
+    Change change = newChange();
+    CommentAddedEvent orig = new CommentAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    CommentAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void privateStateChangedEvent() {
+    Change change = newChange();
+    PrivateStateChangedEvent orig = new PrivateStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    PrivateStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerAddedEvent() {
+    Change change = newChange();
+    ReviewerAddedEvent orig = new ReviewerAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerDeletedEvent() {
+    Change change = newChange();
+    ReviewerDeletedEvent orig = new ReviewerDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void voteDeletedEvent() {
+    Change change = newChange();
+    VoteDeletedEvent orig = new VoteDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    VoteDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void workinProgressStateChangedEvent() {
+    Change change = newChange();
+    WorkInProgressStateChangedEvent orig = new WorkInProgressStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    WorkInProgressStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void topicChangedEvent() {
+    Change change = newChange();
+    TopicChangedEvent orig = new TopicChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    TopicChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
   private <T> Supplier<T> createSupplier(T value) {
     return Suppliers.memoize(() -> value);
   }
+
+  private Change newChange() {
+    Change change =
+        new Change(
+            Change.key("Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            Change.id(1000),
+            Account.id(1000),
+            BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
+            new Timestamp(System.currentTimeMillis()));
+    return change;
+  }
+
+  private Supplier<AccountAttribute> newAccount(String name) {
+    AccountAttribute account = new AccountAttribute();
+    account.name = name;
+    account.email = name + "@somewhere.com";
+    account.username = name;
+    return Suppliers.ofInstance(account);
+  }
+
+  private void assertSameChangeEvent(ChangeEvent current, ChangeEvent expected) {
+    assertThat(current.changeKey.get()).isEqualTo(expected.changeKey.get());
+    assertThat(current.refName).isEqualTo(expected.refName);
+    assertThat(current.project).isEqualTo(expected.project);
+    assertSameChange(current.change, expected.change);
+  }
+
+  private void assertSameChange(
+      Supplier<ChangeAttribute> currentSupplier, Supplier<ChangeAttribute> expectedSupplier) {
+    ChangeAttribute current = currentSupplier.get();
+    ChangeAttribute expected = expectedSupplier.get();
+    assertThat(current.project).isEqualTo(expected.project);
+    assertThat(current.branch).isEqualTo(expected.branch);
+    assertThat(current.topic).isEqualTo(expected.topic);
+    assertThat(current.id).isEqualTo(expected.id);
+    assertThat(current.number).isEqualTo(expected.number);
+    assertThat(current.subject).isEqualTo(expected.subject);
+    assertThat(current.commitMessage).isEqualTo(expected.commitMessage);
+    assertThat(current.url).isEqualTo(expected.url);
+    assertThat(current.status).isEqualTo(expected.status);
+    assertThat(current.createdOn).isEqualTo(expected.createdOn);
+    assertThat(current.wip).isEqualTo(expected.wip);
+    assertThat(current.isPrivate).isEqualTo(expected.isPrivate);
+  }
+
+  private void assertSameAccount(
+      Supplier<AccountAttribute> currentSupplier, Supplier<AccountAttribute> expectedSupplier) {
+    AccountAttribute current = currentSupplier.get();
+    AccountAttribute expected = expectedSupplier.get();
+    assertThat(current.name).isEqualTo(expected.name);
+    assertThat(current.email).isEqualTo(expected.email);
+    assertThat(current.username).isEqualTo(expected.username);
+  }
+
+  public Supplier<ChangeAttribute> asChangeAttribute(Change change) {
+    ChangeAttribute a = new ChangeAttribute();
+    a.project = change.getProject().get();
+    a.branch = change.getDest().shortName();
+    a.topic = change.getTopic();
+    a.id = change.getKey().get();
+    a.number = change.getId().get();
+    a.subject = change.getSubject();
+    a.commitMessage = "This is a test commit message";
+    a.url = "http://somewhere.com";
+    a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
+    return Suppliers.ofInstance(a);
+  }
+
+  @SuppressWarnings("unchecked")
+  private <E extends Event> E roundTrip(E event) {
+    String json = gson.toJson(event);
+    return (E) gson.fromJson(json, event.getClass());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index b59641d..6163ea7 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -15,32 +15,30 @@
 package com.google.gerrit.server.events;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
+import static com.google.gerrit.entities.Change.Status.NEW;
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.truth.MapSubject;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.junit.Before;
 import org.junit.Test;
 
-public class EventJsonTest extends GerritBaseTests {
+public class EventJsonTest {
   private static final String BRANCH = "mybranch";
   private static final String CHANGE_ID = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
   private static final int CHANGE_NUM = 1000;
@@ -52,12 +50,7 @@
   private static final double TS2 = 1.254344401E9;
   private static final String URL = "http://somewhere.com";
 
-  // Must match StreamEvents#gson. (In master, the definition is refactored to be hared.)
-  private final Gson gson =
-      new GsonBuilder()
-          .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-          .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-          .create();
+  private final Gson gson = new EventGsonProvider().get();
 
   @Before
   public void setTimeForTesting() {
@@ -580,7 +573,7 @@
         Change.key(CHANGE_ID),
         Change.id(CHANGE_NUM),
         Account.id(9999),
-        Branch.nameKey(Project.nameKey(PROJECT), BRANCH),
+        BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
         TimeUtil.nowTs());
   }
 
@@ -591,7 +584,7 @@
   private Supplier<ChangeAttribute> asChangeAttribute(Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
+    a.branch = change.getDest().shortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
     a.number = change.getId().get();
@@ -609,8 +602,9 @@
     // Parse JSON into a raw Java map:
     //  * Doesn't depend on field iteration order.
     //  * Avoids excessively long string literals in asserts.
+    String json = gson.toJson(src);
     Map<Object, Object> map =
-        gson.fromJson(gson.toJson(src), new TypeToken<Map<Object, Object>>() {}.getType());
+        gson.fromJson(json, new TypeToken<Map<Object, Object>>() {}.getType());
     return assertThat(map);
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventTypesTest.java b/javatests/com/google/gerrit/server/events/EventTypesTest.java
index dd5c7f9..c822d6c 100644
--- a/javatests/com/google/gerrit/server/events/EventTypesTest.java
+++ b/javatests/com/google/gerrit/server/events/EventTypesTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EventTypesTest extends GerritBaseTests {
+public class EventTypesTest {
   public static class TestEvent extends Event {
     private static final String TYPE = "test-event";
 
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 4a1f47c..2485613 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.server.extensions.webui;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -32,16 +35,14 @@
 import com.google.gerrit.server.permissions.PermissionBackendCondition;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Set;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-public class UiActionsTest extends GerritBaseTests {
+public class UiActionsTest {
 
   private static class FakeForProject extends ForProject {
     private boolean allowValueQueries = true;
@@ -112,7 +113,7 @@
 
         @Override
         public Account.Id getAccountId() {
-          return new Account.Id(1);
+          return Account.id(1);
         }
       };
     }
@@ -132,9 +133,7 @@
 
     // Set up the Mock to expect a call of bulkEvaluateTest to only contain cond{1,2} since cond3
     // needs to be identified as duplicate and not called out explicitly.
-    PermissionBackend permissionBackendMock = EasyMock.createMock(PermissionBackend.class);
-    permissionBackendMock.bulkEvaluateTest(ImmutableSet.of(cond1, cond2));
-    EasyMock.replay(permissionBackendMock);
+    PermissionBackend permissionBackendMock = mock(PermissionBackend.class);
 
     UiActions.evaluatePermissionBackendConditions(
         permissionBackendMock, ImmutableList.of(cond1, cond2, cond3));
@@ -143,6 +142,8 @@
     // the value of cond1 and issues no additional call to PermissionBackend.
     forProject.disallowValueQueries();
 
+    verify(permissionBackendMock, only()).bulkEvaluateTest(ImmutableSet.of(cond1, cond2));
+
     // Assert the values of all conditions
     assertThat(cond1.value()).isFalse();
     assertThat(cond2.value()).isTrue();
diff --git a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
index cc648bf..8b5705b 100644
--- a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
+++ b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -15,32 +15,31 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.replay;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Comment.Range;
-import com.google.gerrit.reviewdb.client.FixReplacement;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
-public class FixReplacementInterpreterTest extends GerritBaseTests {
-  private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
-  private final Repository repository = createMock(Repository.class);
-  private final ProjectState projectState = createMock(ProjectState.class);
-  private final ObjectId patchSetCommitId = createMock(ObjectId.class);
+public class FixReplacementInterpreterTest {
+  private final FileContentUtil fileContentUtil = mock(FileContentUtil.class);
+  private final Repository repository = mock(Repository.class);
+  private final ProjectState projectState = mock(ProjectState.class);
+  private final ObjectId patchSetCommitId = mock(ObjectId.class);
   private final String filePath1 = "an/arbitrary/file.txt";
   private final String filePath2 = "another/arbitrary/file.txt";
 
@@ -68,7 +67,6 @@
         new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
     mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications =
         toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
     List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
@@ -99,7 +97,6 @@
     FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
     assertThatList(treeModifications)
         .onlyElement()
@@ -114,7 +111,6 @@
         new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
     assertThatList(treeModifications)
         .onlyElement()
@@ -128,7 +124,6 @@
     FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
     assertThatList(treeModifications)
         .onlyElement()
@@ -144,7 +139,6 @@
         new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications =
         toTreeModifications(fixReplacement1, fixReplacement2);
     assertThatList(treeModifications)
@@ -154,6 +148,22 @@
         .isEqualTo("First line\nA modification\nThird line\n");
   }
 
+  @Test()
+  public void startAfterEndOfLineMarkThrowsAnException() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 11, 2, 6), "A modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
+  }
+
+  @Test()
+  public void endAfterEndOfLineMarkThrowsAnException() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(2, 0, 2, 12), "A modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
+  }
+
   @Test
   public void replacementsMayTouch() throws Exception {
     FixReplacement fixReplacement1 =
@@ -162,7 +172,6 @@
         new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications =
         toTreeModifications(fixReplacement1, fixReplacement2);
     assertThatList(treeModifications)
@@ -178,7 +187,6 @@
         new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
     assertThatList(treeModifications)
         .onlyElement()
@@ -188,6 +196,34 @@
   }
 
   @Test
+  public void replacementsCanChangeLastLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "New content\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\nNew content\n");
+  }
+
+  @Test
+  public void replacementsCanChangeLastLineWithoutEOLMark() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(3, 0, 3, 10), "New content\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line");
+
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\nNew content\n");
+  }
+
+  @Test
   public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
     FixReplacement fixReplacement1 =
         new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
@@ -198,7 +234,6 @@
         new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
     mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications =
         toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
     List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
@@ -219,7 +254,6 @@
     FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
     assertThatList(treeModifications)
         .onlyElement()
@@ -238,7 +272,6 @@
         new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
 
-    replay(fileContentUtil);
     List<TreeModification> treeModifications =
         toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
     assertThatList(treeModifications)
@@ -255,10 +288,7 @@
         new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -267,10 +297,7 @@
         new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -279,10 +306,7 @@
         new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -291,10 +315,7 @@
         new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -303,16 +324,12 @@
         new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
-    replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   private void mockFileContent(String filePath, String fileContent) throws Exception {
-    EasyMock.expect(
-            fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
-        .andReturn(BinaryResult.create(fileContent));
+    when(fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
+        .thenReturn(BinaryResult.create(fileContent));
   }
 
   private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
diff --git a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
index 309f726..ba80c02 100644
--- a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -15,25 +15,27 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LineIdentifierTest extends GerritBaseTests {
+public class LineIdentifierTest {
   @Test
   public void lineNumberMustBePositive() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    exception.expectMessage("positive");
-    lineIdentifier.getStartIndexOfLine(0);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(0));
+    assertThat(thrown).hasMessageThat().contains("positive");
   }
 
   @Test
   public void lineNumberMustIndicateAnAvailableLine() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    exception.expectMessage("Line 3 isn't available");
-    lineIdentifier.getStartIndexOfLine(3);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(3));
+    assertThat(thrown).hasMessageThat().contains("Line 3 isn't available");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
index 185b58c..3447248 100644
--- a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Before;
 import org.junit.Test;
 
-public class StringModifierTest extends GerritBaseTests {
+public class StringModifierTest {
   private final String originalString = "This is the original, unmodified string.";
   private StringModifier stringModifier;
 
@@ -63,20 +63,20 @@
   @Test
   public void replacedPartsMustNotOverlap() {
     stringModifier.replace(0, 9, "");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, 32, "The modified");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(8, 32, "The modified"));
   }
 
   @Test
   public void startIndexMustNotBeGreaterThanEndIndex() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(10, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(10, 9, "something"));
   }
 
   @Test
   public void startIndexMustNotBeNegative() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(-1, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(-1, 9, "something"));
   }
 
   @Test
@@ -90,13 +90,17 @@
 
   @Test
   public void startIndexMustNotBeGreaterThanLengthOfString() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () ->
+            stringModifier.replace(
+                originalString.length() + 1, originalString.length() + 1, "something"));
   }
 
   @Test
   public void endIndexMustNotBeGreaterThanLengthOfString() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () -> stringModifier.replace(8, originalString.length() + 1, "something"));
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index b278bfe..3a8d7e4 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -18,11 +18,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -50,7 +50,7 @@
 @RunWith(JUnit4.class)
 public class DeleteZombieCommentsRefsTest {
   private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
-  private Project.NameKey allUsersProject = new Project.NameKey("All-Users");
+  private Project.NameKey allUsersProject = Project.nameKey("All-Users");
 
   @Test
   public void cleanZombieDraftsSmall() throws Exception {
@@ -169,8 +169,8 @@
   }
 
   private static String getRefName(int changeId, int userId) {
-    Change.Id cId = new Change.Id(changeId);
-    Account.Id aId = new Account.Id(userId);
+    Change.Id cId = Change.id(changeId);
+    Account.Id aId = Account.id(userId);
     return RefNames.refsDraftComments(cId, aId);
   }
 
diff --git a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
index f694299..6175385 100644
--- a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -19,9 +19,8 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.SortedSetMultimap;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -32,7 +31,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupCollectorTest extends GerritBaseTests {
+public class GroupCollectorTest {
   private TestRepository<?> tr;
 
   @Before
@@ -285,7 +284,7 @@
   // TODO(dborowitz): Tests for octopus merges.
 
   private static PatchSet.Id psId(int c, int p) {
-    return new PatchSet.Id(new Change.Id(c), p);
+    return PatchSet.id(Change.id(c), p);
   }
 
   private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
diff --git a/javatests/com/google/gerrit/server/git/JGitConfigTest.java b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
new file mode 100644
index 0000000..9f6b47e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class JGitConfigTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private SitePaths site;
+  private Path gitPath;
+
+  @Before
+  public void setUp() throws IOException {
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(site.etc_dir);
+    gitPath = Files.createDirectories(site.resolve("git"));
+
+    Files.write(
+        site.jgit_config, "[core]\n  trustFolderStat = false\n".getBytes(StandardCharsets.UTF_8));
+    new SystemReaderInstaller(site).start();
+  }
+
+  @Test
+  public void test() throws IOException {
+    try (Repository repo = new FileRepository(gitPath.resolve("foo").toFile())) {
+      assertThat(repo.getConfig().getString("core", null, "trustFolderStat")).isEqualTo("false");
+    }
+  }
+
+  @Test
+  public void openSystemConfigRespectsParent() throws Exception {
+    Config parent = new Config();
+    parent.setString("foo", null, "bar", "value");
+    FileBasedConfig system = SystemReader.getInstance().openSystemConfig(parent, FS.DETECTED);
+    system.load();
+    assertThat(system.getString("core", null, "trustFolderStat")).isEqualTo("false");
+    assertThat(system.getString("foo", null, "bar")).isEqualTo("value");
+  }
+
+  @Test
+  public void openSystemConfigReturnsDifferentInstances() throws Exception {
+    FileBasedConfig system1 = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED);
+    system1.load();
+    assertThat(system1.getString("core", null, "trustFolderStat")).isEqualTo("false");
+
+    FileBasedConfig system2 = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED);
+    system2.load();
+    assertThat(system2.getString("core", null, "trustFolderStat")).isEqualTo("false");
+
+    system1.setString("core", null, "trustFolderStat", "true");
+    assertThat(system1.getString("core", null, "trustFolderStat")).isEqualTo("true");
+    assertThat(system2.getString("core", null, "trustFolderStat")).isEqualTo("false");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index 821a6e6b..8ab7dd2 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.ioutil.HostPlatform;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -36,7 +36,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class LocalDiskRepositoryManagerTest extends GerritBaseTests {
+public class LocalDiskRepositoryManagerTest {
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private Config cfg;
@@ -52,14 +52,15 @@
     repoManager = new LocalDiskRepositoryManager(site, cfg);
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testThatNullBasePathThrowsAnException() {
-    new LocalDiskRepositoryManager(site, new Config());
+    assertThrows(
+        IllegalStateException.class, () -> new LocalDiskRepositoryManager(site, new Config()));
   }
 
   @Test
   public void projectCreation() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     try (Repository repo = repoManager.createRepository(projectA)) {
       assertThat(repo).isNotNull();
     }
@@ -69,112 +70,149 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithEmptyName() throws Exception {
-    repoManager.createRepository(new Project.NameKey(""));
+    assertThrows(
+        RepositoryNotFoundException.class, () -> repoManager.createRepository(Project.nameKey("")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTrailingSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("projectA/"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("projectA/")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithBackSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a\\projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a\\projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationAbsolutePath() throws Exception {
-    repoManager.createRepository(new Project.NameKey("/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationStartingWithDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationContainsDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationDotPathSegment() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/./projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/./projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTwoSlashes() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a//projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a//projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/b.git/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithQuestionMark() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project?A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project?A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPercentageSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project%A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project%A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithWidlcard() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project*A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project*A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithColon() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project:A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project:A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithLessThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project<A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project<A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithGreaterThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project>A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPipe() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project|A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project|A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithDollarSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project$A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project$A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithCarriageReturn() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project\\rA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project\\rA")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreation() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> repoManager.createRepository(Project.nameKey("a")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreationAfterRestart() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> newRepoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
   public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
     try (Repository repo = repoManager.openRepository(projectA)) {
       assertThat(repo).isNotNull();
@@ -182,30 +220,36 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("A"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("A")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchWithSymlink() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
     createSymLink(name, "b.git");
-    repoManager.createRepository(new Project.NameKey("B"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("B")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchAfterRestart() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
 
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("A"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> newRepoManager.createRepository(Project.nameKey("A")));
   }
 
   private void createSymLink(Project.NameKey project, String link) throws IOException {
@@ -215,20 +259,22 @@
     Files.createSymbolicLink(symlink, projectDir);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testOpenRepositoryInvalidName() throws Exception {
-    repoManager.openRepository(new Project.NameKey("project%?|<>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.openRepository(Project.nameKey("project%?|<>A")));
   }
 
   @Test
   public void list() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
 
-    Project.NameKey projectB = new Project.NameKey("path/projectB");
+    Project.NameKey projectB = Project.nameKey("path/projectB");
     createRepository(repoManager.getBasePath(projectB), projectB.get());
 
-    Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
+    Project.NameKey projectC = Project.nameKey("anotherPath/path/projectC");
     createRepository(repoManager.getBasePath(projectC), projectC.get());
     // create an invalid git repo named only .git
     repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index fc79a6d..29f520b 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -15,16 +15,14 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.reset;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -41,7 +39,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
+public class MultiBaseLocalDiskRepositoryManagerTest {
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private Config cfg;
@@ -55,16 +53,15 @@
     site.resolve("git").toFile().mkdir();
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of()).anyTimes();
-    replay(configMock);
+    configMock = mock(RepositoryConfig.class);
+    when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of());
     repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
   }
 
   @Test
   public void defaultRepositoryLocation()
       throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
@@ -89,11 +86,9 @@
   @Test
   public void alternateRepositoryLocation() throws IOException {
     Path alternateBasePath = temporaryFolder.newFolder().toPath();
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
-    reset(configMock);
-    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(alternateBasePath)).anyTimes();
-    replay(configMock);
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
+    when(configMock.getBasePath(someProjectKey)).thenReturn(alternateBasePath);
+    when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(alternateBasePath));
 
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
@@ -116,18 +111,16 @@
 
   @Test
   public void listReturnRepoFromProperLocation() throws IOException {
-    Project.NameKey basePathProject = new Project.NameKey("basePathProject");
-    Project.NameKey altPathProject = new Project.NameKey("altPathProject");
-    Project.NameKey misplacedProject1 = new Project.NameKey("misplacedProject1");
-    Project.NameKey misplacedProject2 = new Project.NameKey("misplacedProject2");
+    Project.NameKey basePathProject = Project.nameKey("basePathProject");
+    Project.NameKey altPathProject = Project.nameKey("altPathProject");
+    Project.NameKey misplacedProject1 = Project.nameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 = Project.nameKey("misplacedProject2");
 
     Path alternateBasePath = temporaryFolder.newFolder().toPath();
 
-    reset(configMock);
-    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getBasePath(misplacedProject2)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(alternateBasePath)).anyTimes();
-    replay(configMock);
+    when(configMock.getBasePath(altPathProject)).thenReturn(alternateBasePath);
+    when(configMock.getBasePath(misplacedProject2)).thenReturn(alternateBasePath);
+    when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(alternateBasePath));
 
     repoManager.createRepository(basePathProject);
     repoManager.createRepository(altPathProject);
@@ -150,11 +143,14 @@
     }
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testRelativeAlternateLocation() {
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(Paths.get("repos"))).anyTimes();
-    replay(configMock);
-    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          configMock = mock(RepositoryConfig.class);
+          when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(Paths.get("repos")));
+          repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+        });
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
index 8c17075..a12a8f7 100644
--- a/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
+++ b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
@@ -36,8 +36,7 @@
             0xaa, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
             0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb);
 
-    Cache.PureRevertKeyProto key =
-        PureRevertCache.key(new Project.NameKey("test"), revert, original);
+    Cache.PureRevertKeyProto key = PureRevertCache.key(Project.nameKey("test"), revert, original);
     assertThat(key)
         .isEqualTo(
             Cache.PureRevertKeyProto.newBuilder()
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
index 87ddc75..4fa0903 100644
--- a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -19,15 +19,14 @@
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class TagSetHolderTest extends GerritBaseTests {
+public class TagSetHolderTest {
   @Test
   public void serializerWithTagSet() throws Exception {
-    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
     holder.setTagSet(new TagSet(holder.getProjectName()));
 
     byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
@@ -46,7 +45,7 @@
 
   @Test
   public void serializerWithoutTagSet() throws Exception {
-    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
 
     byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
     assertThat(TagSetHolderProto.parseFrom(serialized))
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 3ac72be..4a3c930 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -24,13 +24,12 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
 import com.google.gerrit.server.git.TagSet.CachedRef;
 import com.google.gerrit.server.git.TagSet.Tag;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.util.Arrays;
@@ -43,7 +42,7 @@
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.junit.Test;
 
-public class TagSetTest extends GerritBaseTests {
+public class TagSetTest {
   @Test
   public void roundTripToProto() {
     HashMap<String, CachedRef> refs = new HashMap<>();
@@ -60,7 +59,7 @@
     tags.add(
         new Tag(
             ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
-    TagSet tagSet = new TagSet(new Project.NameKey("project"), refs, tags);
+    TagSet tagSet = new TagSet(Project.nameKey("project"), refs, tags);
 
     TagSetProto proto = tagSet.toProto();
     assertThat(proto)
@@ -156,22 +155,24 @@
 
     Map<String, CachedRef> aRefs = a.getRefsForTesting();
     Map<String, CachedRef> bRefs = b.getRefsForTesting();
-    assertThat(ImmutableSortedSet.copyOf(aRefs.keySet()))
-        .named("ref name set")
+    assertWithMessage("ref name set")
+        .that(ImmutableSortedSet.copyOf(aRefs.keySet()))
         .isEqualTo(ImmutableSortedSet.copyOf(bRefs.keySet()));
     for (String name : aRefs.keySet()) {
       CachedRef aRef = aRefs.get(name);
       CachedRef bRef = bRefs.get(name);
-      assertThat(aRef.get()).named("value of ref %s", name).isEqualTo(bRef.get());
-      assertThat(aRef.flag).named("flag of ref %s", name).isEqualTo(bRef.flag);
+      assertWithMessage("value of ref %s", name).that(aRef.get()).isEqualTo(bRef.get());
+      assertWithMessage("flag of ref %s", name).that(aRef.flag).isEqualTo(bRef.flag);
     }
 
     ObjectIdOwnerMap<Tag> aTags = a.getTagsForTesting();
     ObjectIdOwnerMap<Tag> bTags = b.getTagsForTesting();
-    assertThat(getTagIds(aTags)).named("tag ID set").isEqualTo(getTagIds(bTags));
+    assertWithMessage("tag ID set").that(getTagIds(aTags)).isEqualTo(getTagIds(bTags));
     for (Tag aTag : aTags) {
       Tag bTag = bTags.get(aTag);
-      assertThat(aTag.refFlags).named("flags for tag %s", aTag.name()).isEqualTo(bTag.refFlags);
+      assertWithMessage("flags for tag %s", aTag.name())
+          .that(aTag.refFlags)
+          .isEqualTo(bTag.refFlags);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index dedccc2..690a5cc 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -22,12 +22,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.VersionedMetaData.BatchMetaDataUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.Arrays;
@@ -51,7 +50,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class VersionedMetaDataTest extends GerritBaseTests {
+public class VersionedMetaDataTest {
   // If you're considering fleshing out this test and making it more comprehensive, please consider
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
@@ -65,7 +64,7 @@
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-    project = new Project.NameKey("repo");
+    project = Project.nameKey("repo");
     repo = new InMemoryRepository(new DfsRepositoryDescription(project.get()));
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 9fc6da1..c749b77 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -18,17 +18,16 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -44,7 +43,7 @@
 import org.junit.Ignore;
 
 @Ignore
-public class AbstractGroupTest extends GerritBaseTests {
+public class AbstractGroupTest {
   protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
@@ -65,9 +64,9 @@
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
-    serverAccountId = new Account.Id(SERVER_ACCOUNT_NUMBER);
+    serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
     serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
-    userId = new Account.Id(USER_ACCOUNT_NUMBER);
+    userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
 
@@ -124,9 +123,9 @@
   }
 
   private static Optional<Account> getAccount(Account.Id id) {
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
     account.setFullName("Account " + id);
-    return Optional.of(account);
+    return Optional.of(account.build());
   }
 
   private Optional<GroupDescription.Basic> getGroup(AccountGroup.UUID uuid) {
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 120f026..e54ab5d 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -18,10 +18,10 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroupByIdAudit;
+import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
@@ -37,7 +37,7 @@
 
   @Before
   public void setUp() throws Exception {
-    auditLogReader = new AuditLogReader(SERVER_ID, allUsersName);
+    auditLogReader = new AuditLogReader(allUsersName);
   }
 
   @Test
@@ -66,7 +66,7 @@
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
     // User adds account 100002 to the group.
-    Account.Id id = new Account.Id(100002);
+    Account.Id id = Account.id(100002);
     addMembers(uuid, ImmutableSet.of(id));
 
     AccountGroupMemberAudit expAudit2 =
@@ -78,7 +78,7 @@
     // User removes account 100002 from the group.
     removeMembers(uuid, ImmutableSet.of(id));
 
-    expAudit2.removed(userId, getTipTimestamp(uuid));
+    expAudit2 = expAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
         .inOrder();
@@ -94,8 +94,8 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
     addMembers(uuid, ImmutableSet.of(id1, id2));
 
     AccountGroupMemberAudit expAudit2 =
@@ -118,13 +118,13 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    AccountGroupByIdAud expAudit =
+    AccountGroupByIdAudit expAudit =
         createExpGroupAudit(group.getId(), subgroupUuid, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
 
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    expAudit.removed(userId, getTipTimestamp(uuid));
+    expAudit = expAudit.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
   }
 
@@ -140,9 +140,9 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid2));
 
-    AccountGroupByIdAud expAudit1 =
+    AccountGroupByIdAudit expAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expAudit2 =
+    AccountGroupByIdAudit expAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
@@ -158,9 +158,9 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expMemberAudit);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
-    Account.Id id3 = new Account.Id(100004);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
+    Account.Id id3 = Account.id(100004);
     InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
     InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
     InternalGroup subgroup3 = createGroupAsUser(4, "test-group-4");
@@ -180,23 +180,23 @@
 
     // Add one subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit1 =
+    AccountGroupByIdAudit expGroupAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1);
 
     // Remove one account.
     removeMembers(uuid, ImmutableSet.of(id2));
-    expMemberAudit2.removed(userId, getTipTimestamp(uuid));
+    expMemberAudit2 = expMemberAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
         .inOrder();
 
     // Add two subgroups.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid2, subgroupUuid3));
-    AccountGroupByIdAud expGroupAudit2 =
+    AccountGroupByIdAudit expGroupAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expGroupAudit3 =
+    AccountGroupByIdAudit expGroupAudit3 =
         createExpGroupAudit(group.getId(), subgroupUuid3, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
@@ -215,15 +215,15 @@
 
     // Remove two subgroups.
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid3));
-    expGroupAudit1.removed(userId, getTipTimestamp(uuid));
-    expGroupAudit3.removed(userId, getTipTimestamp(uuid));
+    expGroupAudit1 = expGroupAudit1.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
+    expGroupAudit3 = expGroupAudit3.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
         .inOrder();
 
     // Add back one removed subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit4 =
+    AccountGroupByIdAudit expGroupAudit4 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3, expGroupAudit4)
@@ -239,8 +239,8 @@
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(GroupUUID.make(groupName, serverIdent))
-            .setNameKey(new AccountGroup.NameKey(groupName))
-            .setId(new AccountGroup.Id(next))
+            .setNameKey(AccountGroup.nameKey(groupName))
+            .setId(AccountGroup.id(next))
             .build();
     InternalGroupUpdate groupUpdate =
         authorIdent.equals(serverIdent)
@@ -303,12 +303,21 @@
 
   private static AccountGroupMemberAudit createExpMemberAudit(
       AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupMemberAudit(
-        new AccountGroupMemberAudit.Key(id, groupId, addedOn), addedBy);
+    return AccountGroupMemberAudit.builder()
+        .groupId(groupId)
+        .memberId(id)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 
-  private static AccountGroupByIdAud createExpGroupAudit(
+  private static AccountGroupByIdAudit createExpGroupAudit(
       AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupByIdAud(new AccountGroupByIdAud.Key(groupId, uuid, addedOn), addedBy);
+    return AccountGroupByIdAudit.builder()
+        .groupId(groupId)
+        .includeUuid(uuid)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index b4652c9..3303338 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -8,11 +8,11 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common/data/testing:common-data-test-util",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
@@ -20,8 +20,8 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index c9ba72e..b7fe23d 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -17,23 +17,24 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -55,20 +56,20 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupConfigTest extends GerritBaseTests {
+public class GroupConfigTest {
   private Project.NameKey projectName;
   private Repository repository;
   private TestRepository<?> testRepository;
-  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
-  private final AccountGroup.Id groupId = new AccountGroup.Id(123);
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
+  private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
   private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
-    projectName = new Project.NameKey("Test Repository");
+    projectName = Project.nameKey("Test Repository");
     repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
     testRepository = new TestRepository<>(repository);
   }
@@ -95,7 +96,7 @@
 
   @Test
   public void nameOfGroupUpdateOverridesGroupCreation() throws Exception {
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("Another name");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("Another name");
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(groupName).build();
@@ -109,26 +110,13 @@
   @Test
   public void nameOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
+        getPrefilledGroupCreationBuilder().setNameKey(AccountGroup.nameKey("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
-  public void nameOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
@@ -144,13 +132,13 @@
   @Test
   public void idOfNewGroupMustNotBeNegative() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
+        getPrefilledGroupCreationBuilder().setId(AccountGroup.id(-2)).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("ID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
 
@@ -207,7 +195,7 @@
 
   @Test
   public void specifiedOwnerGroupUuidIsRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID("anotherOwnerUuid");
+    AccountGroup.UUID ownerGroupUuid = AccountGroup.uuid("anotherOwnerUuid");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -219,32 +207,17 @@
   }
 
   @Test
-  public void ownerGroupUuidOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void ownerGroupUuidOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -303,8 +276,8 @@
 
   @Test
   public void specifiedMembersAreRespectedForNewGroup() throws Exception {
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -319,8 +292,8 @@
 
   @Test
   public void specifiedSubgroupsAreRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroup1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroup2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroup1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroup2");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -353,9 +326,11 @@
   public void idInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\townerGroupUuid = owners\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -363,9 +338,11 @@
     populateGroupConfig(
         groupUuid, "[group]\n\tname = users\n\tid = -5\n\townerGroupUuid = owners\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -389,9 +366,11 @@
   public void ownerGroupUuidInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\tid = 42\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Owner UUID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
   }
 
   @Test
@@ -430,12 +409,12 @@
         .value()
         .members()
         .containsExactly(
-            new Account.Id(1),
-            new Account.Id(2),
-            new Account.Id(3),
-            new Account.Id(4),
-            new Account.Id(5),
-            new Account.Id(6));
+            Account.id(1),
+            Account.id(2),
+            Account.id(3),
+            Account.id(4),
+            Account.id(5),
+            Account.id(6));
   }
 
   @Test
@@ -443,9 +422,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "One");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -453,9 +432,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "1\t2");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -494,12 +473,12 @@
         .value()
         .subgroups()
         .containsExactly(
-            new AccountGroup.UUID("1"),
-            new AccountGroup.UUID("2"),
-            new AccountGroup.UUID("3"),
-            new AccountGroup.UUID("4"),
-            new AccountGroup.UUID("5"),
-            new AccountGroup.UUID("6"));
+            AccountGroup.uuid("1"),
+            AccountGroup.uuid("2"),
+            AccountGroup.uuid("3"),
+            AccountGroup.uuid("4"),
+            AccountGroup.uuid("5"),
+            AccountGroup.uuid("6"));
   }
 
   @Test
@@ -508,7 +487,7 @@
     populateSubgroupsFile(groupUuid, "1\t2 3");
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
-    assertThatGroup(group).value().subgroups().containsExactly(new AccountGroup.UUID("1\t2 3"));
+    assertThatGroup(group).value().subgroups().containsExactly(AccountGroup.uuid("1\t2 3"));
   }
 
   @Test
@@ -520,13 +499,13 @@
     assertThatGroup(group)
         .value()
         .subgroups()
-        .containsExactly(new AccountGroup.UUID("1\t2"), new AccountGroup.UUID("3"));
+        .containsExactly(AccountGroup.uuid("1\t2"), AccountGroup.uuid("3"));
   }
 
   @Test
   public void nameCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey newName = new AccountGroup.NameKey("New name");
+    AccountGroup.NameKey newName = AccountGroup.nameKey("New name");
 
     InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(newName).build();
     updateGroup(groupUuid, groupUpdate);
@@ -536,41 +515,25 @@
   }
 
   @Test
-  public void nameCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void nameCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
   @Test
   public void nameCanBeUpdatedToEmptyStringIfExplicitlySpecified() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = AccountGroup.nameKey("");
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
@@ -608,7 +571,7 @@
   @Test
   public void ownerGroupUuidCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID newOwnerGroupUuid = new AccountGroup.UUID("New owner");
+    AccountGroup.UUID newOwnerGroupUuid = AccountGroup.uuid("New owner");
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
@@ -619,34 +582,18 @@
   }
 
   @Test
-  public void ownerGroupUuidCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void ownerGroupUuidCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -675,7 +622,7 @@
 
     InternalGroupUpdate laterGroupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupUpdate);
@@ -688,8 +635,8 @@
   @Test
   public void membersCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -710,8 +657,8 @@
   @Test
   public void membersCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -732,8 +679,8 @@
   @Test
   public void subgroupsCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -754,8 +701,8 @@
   @Test
   public void subgroupsCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -798,13 +745,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupUpdate);
@@ -820,13 +766,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupUpdate);
@@ -843,19 +788,18 @@
     InternalGroupUpdate initialGroupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
     createGroup(groupCreation, initialGroupUpdate);
 
     // Only update one of the properties.
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupUpdate);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
@@ -870,7 +814,7 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
 
-    AccountGroup.NameKey name = new AccountGroup.NameKey("Robots");
+    AccountGroup.NameKey name = AccountGroup.nameKey("Robots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(name).build();
     groupConfig.setGroupUpdate(groupUpdate1, auditLogFormatter);
     commit(groupConfig);
@@ -907,7 +851,7 @@
     RevCommit commitAfterCreation = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
@@ -990,9 +934,7 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
-            .setOwnerGroupUUID(new AccountGroup.UUID("Another owner"))
-            .build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1008,8 +950,7 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(
-                members -> Sets.union(members, ImmutableSet.of(new Account.Id(10))))
+            .setMemberModification(members -> Sets.union(members, ImmutableSet.of(Account.id(10))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1027,8 +968,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setSubgroupModification(
-                subgroups ->
-                    Sets.union(subgroups, ImmutableSet.of(new AccountGroup.UUID("subgroup"))))
+                subgroups -> Sets.union(subgroups, ImmutableSet.of(AccountGroup.uuid("subgroup"))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1045,7 +985,7 @@
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -1128,7 +1068,7 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1161,7 +1101,7 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1187,7 +1127,7 @@
 
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1202,7 +1142,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
             .build();
     updateGroup(groupUuid, groupUpdate);
@@ -1220,7 +1160,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
@@ -1248,7 +1188,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
@@ -1281,14 +1221,14 @@
   public void groupCanBeLoadedAtASpecificRevision() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    AccountGroup.NameKey firstName = new AccountGroup.NameKey("Bots");
+    AccountGroup.NameKey firstName = AccountGroup.nameKey("Bots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(firstName).build();
     updateGroup(groupUuid, groupUpdate1);
 
     RevCommit commitAfterUpdate1 = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Robots")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Robots")).build();
     updateGroup(groupUuid, groupUpdate2);
 
     GroupConfig groupConfig =
@@ -1315,7 +1255,7 @@
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     createGroup(groupCreation, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1324,8 +1264,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithMembersContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     AuditLogFormatter auditLogFormatter =
@@ -1335,7 +1275,7 @@
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1349,8 +1289,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithSubgroupsContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     AuditLogFormatter auditLogFormatter =
@@ -1374,8 +1314,8 @@
 
   @Test
   public void commitMessageOfMemberAdditionContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1385,7 +1325,7 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate, auditLogFormatter);
 
@@ -1396,8 +1336,8 @@
 
   @Test
   public void commitMessageOfMemberRemovalContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1407,13 +1347,13 @@
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
 
     InternalGroupUpdate groupUpdate2 =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
 
@@ -1423,8 +1363,8 @@
 
   @Test
   public void commitMessageOfSubgroupAdditionContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1446,8 +1386,8 @@
 
   @Test
   public void commitMessageOfSubgroupRemovalContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1478,11 +1418,11 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Old name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Old name")).build();
     updateGroup(groupUuid, groupUpdate1);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("New name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("New name")).build();
     updateGroup(groupUuid, groupUpdate2);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1492,11 +1432,11 @@
 
   @Test
   public void commitMessageFootersCanBeMixed() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1506,16 +1446,16 @@
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Old name"))
-            .setMemberModification(members -> ImmutableSet.of(account7.getId()))
+            .setName(AccountGroup.nameKey("Old name"))
+            .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group2.getGroupUUID()))
             .build();
     updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
 
     InternalGroupUpdate groupUpdate2 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("New name"))
-            .setMemberModification(members -> ImmutableSet.of(account13.getId()))
+            .setName(AccountGroup.nameKey("New name"))
+            .setMemberModification(members -> ImmutableSet.of(account13.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
     updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
@@ -1623,7 +1563,7 @@
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repository);
+            GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repository);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
@@ -1641,9 +1581,9 @@
   }
 
   private static Account createAccount(Account.Id id, String name) {
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
     account.setFullName(name);
-    return account;
+    return account.build();
   }
 
   private static GroupDescription.Basic createGroup(AccountGroup.UUID uuid, String name) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 2a8d551..df97e88 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.group.db;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.common.data.testing.GroupReferenceSubject.groupReferences;
+import static com.google.gerrit.entities.RefNames.REFS_GROUPNAMES;
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -27,19 +27,18 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.testing.GroupReferenceSubject;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.testing.CommitInfoSubject;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.GitTestUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gerrit.truth.ListSubject;
@@ -69,13 +68,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupNameNotesTest extends GerritBaseTests {
+public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
-  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
 
   private AtomicInteger idCounter;
   private AllUsersName allUsersName;
@@ -105,19 +104,21 @@
 
   @Test
   public void uuidOfNewGroupMustNotBeNull() throws Exception {
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName));
   }
 
   @Test
   public void nameOfNewGroupMustNotBeNull() throws Exception {
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null));
   }
 
   @Test
   public void nameOfNewGroupMayBeEmpty() throws Exception {
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = AccountGroup.nameKey("");
     createGroup(groupUuid, emptyName);
 
     Optional<GroupReference> groupReference = loadGroup(emptyName);
@@ -128,17 +129,19 @@
   public void newGroupMustNotReuseNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("AnotherGroup");
-    exception.expect(DuplicateKeyException.class);
-    exception.expectMessage(groupName.get());
-    GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("AnotherGroup");
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () -> GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName));
+    assertThat(thrown).hasMessageThat().contains(groupName.get());
   }
 
   @Test
   public void newGroupMayReuseUuidOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherName);
 
     Optional<GroupReference> group1 = loadGroup(groupName);
@@ -151,7 +154,7 @@
   public void groupCanBeRenamed() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> groupReference = loadGroup(anotherName);
@@ -163,7 +166,7 @@
   public void previousNameOfGroupCannotBeUsedAfterRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> group = loadGroup(groupName);
@@ -173,61 +176,75 @@
   @Test
   public void groupCannotBeRenamedToNull() throws Exception {
     createGroup(groupUuid, groupName);
-
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null));
   }
 
   @Test
   public void oldNameOfGroupMustBeSpecifiedForRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenOldNameIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherOldName = new AccountGroup.NameKey("contributors");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage(anotherOldName.get());
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, anotherOldName, anotherName);
+    AccountGroup.NameKey anotherOldName = AccountGroup.nameKey("contributors");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, anotherOldName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(anotherOldName.get());
   }
 
   @Test
   public void groupCannotBeRenamedToNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherGroupName);
 
-    exception.expect(DuplicateKeyException.class);
-    exception.expectMessage(anotherGroupName.get());
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherGroupName);
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, groupName, anotherGroupName));
+    assertThat(thrown).hasMessageThat().contains(anotherGroupName.get());
   }
 
   @Test
   public void groupCannotBeRenamedWithoutSpecifiedUuid() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenUuidIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage(groupUuid.get());
-    GroupNameNotes.forRename(allUsersName, repo, anotherGroupUuid, groupName, anotherName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, anotherGroupUuid, groupName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(groupUuid.get());
   }
 
   @Test
@@ -248,8 +265,8 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterFurtherGroup = log();
@@ -262,7 +279,7 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterRename = log();
@@ -298,7 +315,7 @@
   public void newCommitIsNotCreatedWhenCommittingGroupRenamingTwice() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     GroupNameNotes groupNameNotes =
         GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherName);
 
@@ -323,7 +340,7 @@
   public void commitMessageMentionsGroupRenaming() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commits = log();
@@ -341,18 +358,18 @@
 
   @Test
   public void nonExistentGroupCannotBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
 
-    Optional<GroupReference> group = loadGroup(new AccountGroup.NameKey("admins"));
+    Optional<GroupReference> group = loadGroup(AccountGroup.nameKey("admins"));
     assertThatGroup(group).isAbsent();
   }
 
   @Test
   public void specificGroupCanBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
-    createGroup(new AccountGroup.UUID("admins-ABC"), new AccountGroup.NameKey("admins"));
+    createGroup(AccountGroup.uuid("admins-ABC"), AccountGroup.nameKey("admins"));
 
     Optional<GroupReference> group = loadGroup(groupName);
     assertThatGroup(group).value().groupUuid().isEqualTo(groupUuid);
@@ -367,11 +384,11 @@
 
   @Test
   public void allGroupsCanBeLoaded() throws Exception {
-    AccountGroup.UUID groupUuid1 = new AccountGroup.UUID("contributors-MN");
-    AccountGroup.NameKey groupName1 = new AccountGroup.NameKey("contributors");
+    AccountGroup.UUID groupUuid1 = AccountGroup.uuid("contributors-MN");
+    AccountGroup.NameKey groupName1 = AccountGroup.nameKey("contributors");
     createGroup(groupUuid1, groupName1);
-    AccountGroup.UUID groupUuid2 = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey groupName2 = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID groupUuid2 = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey groupName2 = AccountGroup.nameKey("admins");
     createGroup(groupUuid2, groupName2);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -384,7 +401,7 @@
   @Test
   public void loadedGroupsContainGroupsWithDuplicateGroupUuids() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherGroupName);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -424,10 +441,10 @@
     GroupReference g1 = newGroup("a");
     GroupReference g2 = newGroup("b");
 
-    try (TestRepository<?> tr = new TestRepository<>(repo)) {
+    try (TestRepository<Repository> tr = new TestRepository<>(repo)) {
       ObjectId k1 = getNoteKey(g1);
       ObjectId k2 = getNoteKey(g2);
-      ObjectId k3 = GroupNameNotes.getNoteKey(new AccountGroup.NameKey("c"));
+      ObjectId k3 = GroupNameNotes.getNoteKey(AccountGroup.nameKey("c"));
       PersonIdent ident = newPersonIdent();
       ObjectId origCommitId =
           tr.branch(REFS_GROUPNAMES)
@@ -481,14 +498,14 @@
   @Test
   public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name2"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name2"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid2"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid2"), "name1"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"));
   }
 
   @Test
@@ -529,8 +546,7 @@
     PersonIdent serverIdent = newPersonIdent();
 
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repo);
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repo);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
@@ -538,7 +554,7 @@
 
   private GroupReference newGroup(String name) {
     int id = idCounter.incrementAndGet();
-    return new GroupReference(new AccountGroup.UUID(name + "-" + id), name);
+    return new GroupReference(AccountGroup.uuid(name + "-" + id), name);
   }
 
   private static PersonIdent newPersonIdent() {
@@ -546,7 +562,7 @@
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
-    return GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g.getName()));
+    return GroupNameNotes.getNoteKey(AccountGroup.nameKey(g.getName()));
   }
 
   private void updateAllGroups(PersonIdent ident, GroupReference... groupRefs) throws Exception {
@@ -562,12 +578,13 @@
     try (ObjectInserter inserter = repo.newObjectInserter()) {
       BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
       PersonIdent ident = newPersonIdent();
-      try {
-        GroupNameNotes.updateAllGroups(repo, inserter, bru, Arrays.asList(groupRefs), ident);
-        assert_().fail("Expected IllegalArgumentException");
-      } catch (IllegalArgumentException e) {
-        assertThat(e).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
-      }
+      IllegalArgumentException thrown =
+          assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  GroupNameNotes.updateAllGroups(
+                      repo, inserter, bru, Arrays.asList(groupRefs), ident));
+      assertThat(thrown).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index a5b04ee..9025691 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -17,9 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
 
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
 import java.util.List;
 import org.junit.Test;
@@ -30,7 +30,7 @@
   public void groupNamesRefIsMissing() throws Exception {
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -40,7 +40,7 @@
     updateGroupNamesRef("g-2", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -50,7 +50,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems).isEmpty();
   }
 
@@ -59,7 +59,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -72,7 +72,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("group note of name 'g-1' claims to represent name of 'g-2'"));
   }
@@ -82,7 +82,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -97,7 +97,7 @@
     updateGroupNamesRef("g-1", "[invalid");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -105,7 +105,7 @@
   }
 
   private void updateGroupNamesRef(String groupName, String content) throws Exception {
-    String nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(groupName)).getName();
+    String nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(groupName)).getName();
     GroupTestUtil.updateGroupFile(
         allUsersRepo, serverIdent, RefNames.REFS_GROUPNAMES, nameKey, content);
   }
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index c69fa20..92a5fbe 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -21,37 +21,39 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class AccountFieldTest extends GerritBaseTests {
+public class AccountFieldTest {
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(1), TimeUtil.nowTs());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     List<String> values =
-        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(allUsersName, account)));
+        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(account.build())));
     assertThat(values).hasSize(1);
     String expectedValue =
-        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
+        allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
     assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
   }
 
   @Test
   public void externalIdStateFieldValues() throws Exception {
-    Account.Id id = new Account.Id(1);
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Id id = Account.id(1);
+    Account account =
+        Account.builder(id, TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
     ExternalId extId1 =
         ExternalId.create(
             ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
@@ -69,7 +71,7 @@
     List<String> values =
         toStrings(
             AccountField.EXTERNAL_ID_STATE.get(
-                AccountState.forAccount(null, account, ImmutableSet.of(extId1, extId2))));
+                AccountState.forAccount(account, ImmutableSet.of(extId1, extId2))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
     assertThat(values).containsExactly(expectedValue1, expectedValue2);
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 758c304..b817b80 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -23,12 +24,11 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
 import java.util.Collections;
@@ -38,7 +38,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeFieldTest extends GerritBaseTests {
+public class ChangeFieldTest {
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
@@ -53,9 +53,9 @@
   public void reviewerFieldValues() {
     Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
     Timestamp t1 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
+    t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
     Timestamp t2 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
+    t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
@@ -63,7 +63,7 @@
         .containsExactly(
             "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
 
-    assertThat(ChangeField.parseReviewerFieldValues(new Change.Id(1), values)).isEqualTo(reviewers);
+    assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
 
   @Test
@@ -75,7 +75,7 @@
                         SubmitRecord.Status.OK,
                         label(SubmitRecord.Label.Status.MAY, "Label-1", null),
                         label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
-                new Account.Id(1)))
+                Account.id(1)))
         .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
   }
 
@@ -142,7 +142,7 @@
     l.status = status;
     l.label = label;
     if (appliedBy != null) {
-      l.appliedBy = new Account.Id(appliedBy);
+      l.appliedBy = Account.id(appliedBy);
     }
     return l;
   }
@@ -153,8 +153,8 @@
         ChangeField.storedSubmitRecords(recordList).stream()
             .map(s -> new String(s, UTF_8))
             .collect(toList());
-    assertThat(ChangeField.parseSubmitRecords(stored))
-        .named("JSON %s" + stored)
+    assertWithMessage("JSON %s" + stored)
+        .that(ChangeField.parseSubmitRecords(stored))
         .isEqualTo(recordList);
   }
 }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index fd23da3..f5d3bf7 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -16,32 +16,32 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.entities.Change.Status.MERGED;
+import static com.google.gerrit.entities.Change.Status.NEW;
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.or;
-import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
-import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
 import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeIndexRewriterTest extends GerritBaseTests {
+public class ChangeIndexRewriterTest {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
 
   private FakeChangeIndex index;
@@ -196,9 +196,8 @@
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("Unsupported index predicate: file:a");
-    rewrite(in);
+    QueryParseException thrown = assertThrows(QueryParseException.class, () -> rewrite(in));
+    assertThat(thrown).hasMessageThat().contains("Unsupported index predicate: file:a");
   }
 
   @Test
@@ -207,9 +206,9 @@
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("too many terms in query");
-    rewrite(parse(q + " OR file:d"));
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:d")));
+    assertThat(thrown).hasMessageThat().contains("too many terms in query");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 34c5717..a23ccab 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -15,23 +15,24 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import org.junit.Ignore;
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = new Schema<>(1, ImmutableList.of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 = new Schema<>(1, false, ImmutableList.of(ChangeField.STATUS));
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      new Schema<>(
+          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index a38eabe..c887875 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -15,20 +15,19 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.RefState;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.util.stream.Stream;
 import org.eclipse.jgit.junit.TestRepository;
@@ -37,14 +36,14 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class StalenessCheckerTest extends GerritBaseTests {
+public class StalenessCheckerTest {
   private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
   private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
 
-  private static final Project.NameKey P1 = new Project.NameKey("project1");
-  private static final Project.NameKey P2 = new Project.NameKey("project2");
+  private static final Project.NameKey P1 = Project.nameKey("project1");
+  private static final Project.NameKey P2 = Project.nameKey("project2");
 
-  private static final Change.Id C = new Change.Id(1234);
+  private static final Change.Id C = Change.id(1234);
 
   private GitRepositoryManager repoManager;
   private Repository r1;
@@ -83,12 +82,7 @@
   }
 
   private static void assertInvalidState(String state) {
-    try {
-      RefState.parseStates(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(IllegalArgumentException.class, () -> RefState.parseStates(byteArrays(state)));
   }
 
   @Test
@@ -155,12 +149,8 @@
   }
 
   private static void assertInvalidPattern(String state) {
-    try {
-      StalenessChecker.parsePatterns(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class, () -> StalenessChecker.parsePatterns(byteArrays(state)));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/ioutil/BUILD b/javatests/com/google/gerrit/server/ioutil/BUILD
index ef02243..ac9530f 100644
--- a/javatests/com/google/gerrit/server/ioutil/BUILD
+++ b/javatests/com/google/gerrit/server/ioutil/BUILD
@@ -10,7 +10,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/server/ioutil",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
diff --git a/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
index c4f32c7..fae8559 100644
--- a/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
@@ -23,14 +23,13 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import org.junit.Test;
 
-public class BasicSerializationTest extends GerritBaseTests {
+public class BasicSerializationTest {
   @Test
   public void testReadVarInt32() throws IOException {
     assertEquals(0x00000000, readVarInt32(r(b(0))));
diff --git a/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
index 9f5e60a..fe642ba 100644
--- a/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.ioutil;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import org.junit.Assert;
 import org.junit.Test;
 
-public class ColumnFormatterTest extends GerritBaseTests {
+public class ColumnFormatterTest {
   /**
    * Holds an in-memory {@link java.io.PrintWriter} object and allows comparisons of its contents to
    * a supplied string via an assert statement.
diff --git a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
index 40fd71f..9bb6951 100644
--- a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HexFormatTest extends GerritBaseTests {
+public class HexFormatTest {
 
   @Test
   public void fromInt() {
diff --git a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
index 33b1c4f..048d59d 100644
--- a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
@@ -18,11 +18,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.junit.Test;
 
-public class RegexListSearcherTest extends GerritBaseTests {
+public class RegexListSearcherTest {
   private static final ImmutableList<String> EMPTY = ImmutableList.of();
 
   @Test
@@ -58,7 +57,7 @@
   }
 
   private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
-    assertThat(inputs).isOrdered();
+    assertThat(inputs).isInOrder();
     assertThat(RegexListSearcher.ofStrings(re).search(inputs))
         .containsExactlyElementsIn(expected)
         .inOrder();
diff --git a/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
index 817b317..04f806d 100644
--- a/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class StringUtilTest extends GerritBaseTests {
+public class StringUtilTest {
   /** Test the boundary condition that the first character of a string should be escaped. */
   @Test
   public void escapeFirstChar() {
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 463decf..733d784 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -17,26 +17,71 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.truth.Expect;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
-public class LoggingContextAwareExecutorServiceTest extends GerritBaseTests {
+public class LoggingContextAwareExecutorServiceTest {
   @Rule public final Expect expect = Expect.create();
 
+  @Inject @GerritServerConfig private Config config;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private PerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger =
+        new PerformanceLogger() {
+          @Override
+          public void log(String operation, long durationMs, Metadata metadata) {
+            // do nothing
+          }
+        };
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+  }
+
   @Test
   public void loggingContextPropagationToBackgroundThread() throws Exception {
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
-    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar");
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(config, performanceLoggers)) {
+      // Create a performance log record.
+      TraceContext.newTimer("test").close();
+
       SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
 
       ExecutorService executor =
           new LoggingContextAwareExecutorService(Executors.newFixedThreadPool(1));
@@ -52,17 +97,32 @@
                 expect
                     .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
                     .isTrue();
+                expect.that(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+                // Create another performance log record. We expect this to be visible in the outer
+                // thread.
+                TraceContext.newTimer("test2").close();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
               })
           .get();
 
-      // Verify that tags and force logging flag in the outer thread are still set.
+      // Verify that logging context values in the outer thread are still set.
       tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      // The performance log record that was added in the inner thread is available in addition to
+      // the performance log record that was created in the outer thread.
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
     }
+
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
   }
 
   private void assertForceLogging(boolean expected) {
diff --git a/javatests/com/google/gerrit/server/logging/MetadataTest.java b/javatests/com/google/gerrit/server/logging/MetadataTest.java
new file mode 100644
index 0000000..f9ae2c1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/MetadataTest.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class MetadataTest {
+
+  @Test
+  public void stringForLoggingOmitsEmptyOptionalValuesAndReformatsOptionalValuesThatArePresent() {
+    Metadata metadata = Metadata.builder().accountId(1000001).branchName("refs/heads/foo").build();
+    assertThat(metadata.toStringForLoggingLazy().evaluate())
+        .isEqualTo("Metadata{accountId=1000001, branchName=refs/heads/foo, pluginMetadata=[]}");
+  }
+
+  @Test
+  public void
+      stringForLoggingOmitsEmptyOptionalValuesAndReformatsOptionalValuesThatArePresentNoFieldsSet() {
+    Metadata metadata = Metadata.builder().build();
+    assertThat(metadata.toStringForLoggingLazy().evaluate())
+        .isEqualTo("Metadata{pluginMetadata=[]}");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index 113f26c..f6f3b46 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -15,19 +15,18 @@
 package com.google.gerrit.server.logging;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.junit.Before;
 import org.junit.Test;
 
-public class MutableTagsTest extends GerritBaseTests {
+public class MutableTagsTest {
   private MutableTags tags;
 
   @Before
@@ -167,11 +166,7 @@
   }
 
   private void assertNullPointerException(String expectedMessage, Runnable r) {
-    try {
-      r.run();
-      assert_().fail("expected NullPointerException");
-    } catch (NullPointerException e) {
-      assertThat(e.getMessage()).isEqualTo(expectedMessage);
-    }
+    NullPointerException thrown = assertThrows(NullPointerException.class, () -> r.run());
+    assertThat(thrown).hasMessageThat().isEqualTo(expectedMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
new file mode 100644
index 0000000..ed4325d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -0,0 +1,382 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PerformanceLogContextTest {
+  @Inject @GerritServerConfig private Config config;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  // In this test setup this gets the DisabledMetricMaker injected. This means it doesn't record any
+  // metric, but performance log records are still created.
+  @Inject private MetricMaker metricMaker;
+
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+    LoggingContext.getInstance().performanceLogging(false);
+  }
+
+  @Test
+  public void traceTimersInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1", Metadata.empty()),
+            PerformanceLogEntry.create(
+                "test2", Metadata.builder().accountId(1000000).changeId(123).build()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void traceTimersOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    TraceContext.newTimer("test1").close();
+    TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+        .close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<Integer> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build());
+      timer1.start(1000000).close();
+
+      Timer2<Integer, Integer> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build());
+      timer2.start(1000000, 123).close();
+
+      Timer3<Integer, Integer, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build(),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer3.start(1000000, 123, "foo/bar").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(4);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1/latency", Metadata.empty()),
+            PerformanceLogEntry.create(
+                "test2/latency", Metadata.builder().accountId(1000000).build()),
+            PerformanceLogEntry.create(
+                "test3/latency", Metadata.builder().accountId(1000000).changeId(123).build()),
+            PerformanceLogEntry.create(
+                "test4/latency",
+                Metadata.builder().accountId(1000000).changeId(123).projectName("foo/bar").build()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLogNullValuesAllowed() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer1<String> timer1 =
+          metricMaker.newTimer(
+              "test1/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer1.start(null).close();
+
+      Timer2<String, String> timer2 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("branch", Metadata.Builder::branchName).build());
+      timer2.start(null, null).close();
+
+      Timer3<String, String, String> timer3 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("branch", Metadata.Builder::branchName).build(),
+              Field.ofString("revision", Metadata.Builder::revision).build());
+      timer3.start(null, null, null).close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(3);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1/latency", Metadata.empty()),
+            PerformanceLogEntry.create("test2/latency", Metadata.empty()),
+            PerformanceLogEntry.create("test3/latency", Metadata.empty()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    Timer0 timer0 =
+        metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+    timer0.start().close();
+
+    Timer1<Integer> timer1 =
+        metricMaker.newTimer(
+            "test2/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build());
+    timer1.start(1000000).close();
+
+    Timer2<Integer, Integer> timer2 =
+        metricMaker.newTimer(
+            "test3/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build(),
+            Field.ofInteger("change", Metadata.Builder::changeId).build());
+    timer2.start(1000000, 123).close();
+
+    Timer3<Integer, Integer, String> timer3 =
+        metricMaker.newTimer(
+            "test4/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build(),
+            Field.ofInteger("change", Metadata.Builder::changeId).build(),
+            Field.ofString("project", Metadata.Builder::projectName).build());
+    timer3.start(1000000, 123, "value3").close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<Integer> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("accoutn", Metadata.Builder::accountId).build());
+      timer1.start(1000000).close();
+
+      Timer2<Integer, Integer> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build());
+      timer2.start(1000000, 123).close();
+
+      Timer3<Integer, Integer, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build(),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer3.start(1000000, 123, "foo/bar").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void nestingPerformanceLogContextsIsPossible() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext1 =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+      try (PerformanceLogContext traceContext2 =
+          new PerformanceLogContext(config, performanceLoggers)) {
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+        assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+        TraceContext.newTimer("test2").close();
+        TraceContext.newTimer("test3").close();
+
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+      }
+
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+    }
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_PerformanceLogContextTest_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 19b2eeb..13f2035 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.logging;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.junit.After;
 import org.junit.Test;
 
-public class TraceContextTest extends GerritBaseTests {
+public class TraceContextTest {
   @After
   public void cleanup() {
     LoggingContext.getInstance().clearTags();
@@ -237,6 +237,22 @@
     }
   }
 
+  @Test
+  public void operationForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null));
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null, Metadata.empty()));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                null, Metadata.builder().accountId(1000000).changeId(123).build()));
+  }
+
+  @Test
+  public void metadataForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer("test", null));
+  }
+
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
     SortedMap<String, SortedSet<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index f8a613a..9dcb08c 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -18,11 +18,10 @@
 
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailMessage;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.Instant;
 import org.junit.Test;
 
-public class AutoReplyMailFilterTest extends GerritBaseTests {
+public class AutoReplyMailFilterTest {
 
   private AutoReplyMailFilter autoReplyMailFilter = new AutoReplyMailFilter();
 
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
index 78116ed..f4fbc78 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
@@ -20,11 +20,10 @@
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.junit.Test;
 
-public class CommentFormatterTest extends GerritBaseTests {
+public class CommentFormatterTest {
   private void assertBlock(
       List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
     CommentFormatter.Block block = list.get(index);
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 0682bb3..78cefdf 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -16,11 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collections;
 import org.junit.Test;
 
-public class CommentSenderTest extends GerritBaseTests {
+public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
       super(null, null, null, null, null);
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 537ebff..74f44a1 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -15,20 +15,17 @@
 package com.google.gerrit.server.mail.send;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
@@ -37,7 +34,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class FromAddressGeneratorProviderTest extends GerritBaseTests {
+public class FromAddressGeneratorProviderTest {
   private Config config;
   private PersonIdent ident;
   private AccountCache accountCache;
@@ -46,7 +43,7 @@
   public void setUp() throws Exception {
     config = new Config();
     ident = new PersonIdent("NAME", "e@email", 0, 0);
-    accountCache = createStrictMock(AccountCache.class);
+    accountCache = mock(AccountCache.class);
   }
 
   private FromAddressGenerator create() {
@@ -86,12 +83,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name);
     assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -101,12 +97,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(null, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isNull();
     assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -116,23 +111,21 @@
     final String name = "A U. Thor";
     final Account.Id user = user(name, null);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name + " (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
   public void USER_NullUser() {
     setFrom("USER");
-    replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(ident.getName());
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyZeroInteractions(accountCache);
   }
 
   @Test
@@ -143,12 +136,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name);
     assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -159,12 +151,11 @@
     final String email = "a.u.thor@test.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name + " (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -176,12 +167,11 @@
     final String email = "a.u.thor@test.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name);
     assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -193,12 +183,11 @@
     final String email = "a.u.thor@test.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name + " (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -209,12 +198,11 @@
     final String email = "a.u.thor@test.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name);
     assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -237,23 +225,21 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = userNoLookup(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(ident.getName());
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyZeroInteractions(accountCache);
   }
 
   @Test
   public void SERVER_NullUser() {
     setFrom("SERVER");
-    replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(ident.getName());
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyZeroInteractions(accountCache);
   }
 
   @Test
@@ -276,12 +262,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name + " (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -291,12 +276,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(null, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -306,23 +290,21 @@
     final String name = "A U. Thor";
     final Account.Id user = user(name, null);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(name + " (Code Review)");
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
   public void MIXED_NullUser() {
     setFrom("MIXED");
-    replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(ident.getName());
     assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
+    verifyZeroInteractions(accountCache);
   }
 
   @Test
@@ -333,12 +315,11 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(name, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo("A " + name + " B");
     assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
+    verifyAccountCacheGet(user);
   }
 
   @Test
@@ -348,42 +329,42 @@
     final String email = "a.u.thor@test.example.com";
     final Account.Id user = user(null, email);
 
-    replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
     assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
   }
 
   @Test
   public void CUSTOM_NullUser() {
     setFrom("A ${user} B <my.server@email.address>");
 
-    replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
     assertThat(r.getName()).isEqualTo(ident.getName());
     assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
   }
 
   private Account.Id user(String name, String email) {
     final AccountState s = makeUser(name, email);
-    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(Optional.of(s));
-    return s.getAccount().getId();
+    when(accountCache.get(eq(s.account().id()))).thenReturn(Optional.of(s));
+    return s.account().id();
+  }
+
+  private void verifyAccountCacheGet(Account.Id id) {
+    verify(accountCache).get(eq(id));
   }
 
   private Account.Id userNoLookup(String name, String email) {
     final AccountState s = makeUser(name, email);
-    return s.getAccount().getId();
+    return s.account().id();
   }
 
   private AccountState makeUser(String name, String email) {
-    final Account.Id userId = new Account.Id(42);
-    final Account account = new Account(userId, TimeUtil.nowTs());
+    final Account.Id userId = Account.id(42);
+    final Account.Builder account = Account.builder(userId, TimeUtil.nowTs());
     account.setFullName(name);
     account.setPreferredEmail(email);
-    return AccountState.forAccount(new AllUsersName(AllUsersNameProvider.DEFAULT), account);
+    return AccountState.forAccount(account.build());
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index a1f318f..7192c55 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -19,16 +19,17 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
@@ -51,9 +52,9 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.AssertableExecutorService;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -63,9 +64,11 @@
 import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
+import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
@@ -75,7 +78,7 @@
 
 @Ignore
 @RunWith(ConfigSuite.class)
-public abstract class AbstractChangeNotesTest extends GerritBaseTests {
+public abstract class AbstractChangeNotesTest {
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
@@ -91,6 +94,7 @@
   protected Project.NameKey project;
   protected RevWalk rw;
   protected TestRepository<InMemoryRepository> tr;
+  protected AssertableExecutorService assertableFanOutExecutor;
 
   @Inject protected IdentifiedUser.GenericFactory userFactory;
 
@@ -110,20 +114,21 @@
     setTimeForTesting();
 
     serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
-    project = new Project.NameKey("test-project");
+    project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account.Builder co = Account.builder(Account.id(1), TimeUtil.nowTs());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
-    accountCache.put(co);
-    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
+    accountCache.put(co.build());
+    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.nowTs());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
-    accountCache.put(ou);
+    accountCache.put(ou.build());
+    assertableFanOutExecutor = new AssertableExecutorService();
 
     injector =
         Guice.createInjector(
@@ -156,13 +161,16 @@
                     .toInstance(serverIdent);
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                bind(ExecutorService.class)
+                    .annotatedWith(FanOutExecutor.class)
+                    .toInstance(assertableFanOutExecutor);
               }
             });
 
     injector.injectMembers(this);
     repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.getId());
-    otherUser = userFactory.create(ou.getId());
+    changeOwner = userFactory.create(co.id());
+    otherUser = userFactory.create(ou.id());
     otherUserId = otherUser.getAccountId();
     internalUser = new InternalUser();
   }
@@ -182,7 +190,7 @@
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
     u.setChangeId(c.getKey().get());
-    u.setBranch(c.getDest().get());
+    u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
     u.commit();
     return c;
@@ -247,7 +255,7 @@
       Timestamp t,
       String message,
       short side,
-      String commitSHA1,
+      ObjectId commitId,
       boolean unresolved) {
     Comment c =
         new Comment(
@@ -260,7 +268,7 @@
             unresolved);
     c.lineNbr = line;
     c.parentUuid = parentUUID;
-    c.revId = commitSHA1;
+    c.setCommitId(commitId);
     c.setRange(range);
     return c;
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index b4d9738..9112756 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -20,8 +20,8 @@
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -31,8 +31,8 @@
   public void keySerializer() throws Exception {
     ChangeNotesCache.Key key =
         ChangeNotesCache.Key.create(
-            new Project.NameKey("project"),
-            new Change.Id(1234),
+            Project.nameKey("project"),
+            Change.id(1234),
             ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
     byte[] serialized = ChangeNotesCache.Key.Serializer.INSTANCE.serialize(key);
     assertThat(ChangeNotesKeyProto.parseFrom(serialized))
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 79dcd5b..013939a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -214,6 +214,26 @@
   }
 
   @Test
+  public void parseAssignee() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Assignee: Change Owner <1@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 2\n"
+            + "Assignee:\n"
+            + "Subject: This is a test change\n");
+  }
+
+  @Test
   public void parseTopic() throws Exception {
     assertParseSucceeds(
         "Update change\n"
@@ -545,19 +565,12 @@
   }
 
   private void assertParseFails(RevCommit commit) throws Exception {
-    try {
-      newParser(commit).parseAll();
-      fail("Expected parse to fail:\n" + commit.getFullMessage());
-    } catch (ConfigInvalidException e) {
-      // Expected
-    }
+    assertThrows(ConfigInvalidException.class, () -> newParser(commit).parseAll());
   }
 
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    LegacyChangeNoteRead reader = injector.getInstance(LegacyChangeNoteRead.class);
-    return new ChangeNotesParser(
-        newChange().getId(), tip, walk, changeNoteJson, reader, args.metrics);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 2931b17..6ece894 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -27,22 +27,23 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -50,23 +51,25 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeNotesStateTest extends GerritBaseTests {
-  private static final Change.Id ID = new Change.Id(123);
+public class ChangeNotesStateTest {
+  private static final Change.Id ID = Change.id(123);
   private static final ObjectId SHA =
       ObjectId.fromString("1234567812345678123456781234567812345678");
   private static final ByteString SHA_BYTES = ObjectIdConverter.create().toByteString(SHA);
   private static final String CHANGE_KEY = "Iabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd";
+  private static final String DEFAULT_SERVER_ID = UUID.randomUUID().toString();
 
   private ChangeColumns cols;
   private ChangeColumnsProto colsProto;
@@ -75,10 +78,10 @@
   public void setUp() throws Exception {
     cols =
         ChangeColumns.builder()
-            .changeKey(new Change.Key(CHANGE_KEY))
+            .changeKey(Change.key(CHANGE_KEY))
             .createdOn(new Timestamp(123456L))
             .lastUpdatedOn(new Timestamp(234567L))
-            .owner(new Account.Id(1000))
+            .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
             .isPrivate(false)
@@ -98,7 +101,7 @@
         newBuilder()
             .columns(
                 cols.toBuilder()
-                    .changeKey(new Change.Key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
+                    .changeKey(Change.key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
                     .build())
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -134,7 +137,7 @@
   @Test
   public void serializeOwner() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().owner(new Account.Id(7777)).build()).build(),
+        newBuilder().columns(cols.toBuilder().owner(Account.id(7777)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -168,7 +171,7 @@
   public void serializeCurrentPatchSetId() throws Exception {
     assertRoundTrip(
         newBuilder()
-            .columns(cols.toBuilder().currentPatchSetId(new PatchSet.Id(ID, 2)).build())
+            .columns(cols.toBuilder().currentPatchSetId(PatchSet.id(ID, 2)).build())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -241,17 +244,6 @@
   }
 
   @Test
-  public void serializeAssignee() throws Exception {
-    assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().assignee(new Account.Id(2000)).build()).build(),
-        ChangeNotesStateProto.newBuilder()
-            .setMetaId(SHA_BYTES)
-            .setChangeId(ID.get())
-            .setColumns(colsProto.toBuilder().setAssignee(2000).setHasAssignee(true))
-            .build());
-  }
-
-  @Test
   public void serializeStatus() throws Exception {
     assertRoundTrip(
         newBuilder().columns(cols.toBuilder().status(Change.Status.MERGED).build()).build(),
@@ -298,7 +290,7 @@
   @Test
   public void serializeRevertOf() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().revertOf(new Change.Id(999)).build()).build(),
+        newBuilder().columns(cols.toBuilder().revertOf(Change.id(999)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -307,21 +299,6 @@
   }
 
   @Test
-  public void serializePastAssignees() throws Exception {
-    assertRoundTrip(
-        newBuilder()
-            .pastAssignees(ImmutableSet.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
-        ChangeNotesStateProto.newBuilder()
-            .setMetaId(SHA_BYTES)
-            .setChangeId(ID.get())
-            .setColumns(colsProto)
-            .addPastAssignee(2002)
-            .addPastAssignee(2001)
-            .build());
-  }
-
-  @Test
   public void serializeHashtags() throws Exception {
     assertRoundTrip(
         newBuilder().hashtags(ImmutableSet.of("tag2", "tag1")).build(),
@@ -336,25 +313,29 @@
 
   @Test
   public void serializePatchSets() throws Exception {
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(ID, 1));
-    ps1.setUploader(new Account.Id(2000));
-    ps1.setRevision(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
-    ps1.setCreatedOn(cols.createdOn());
+    PatchSet ps1 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 1))
+            .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
+            .uploader(Account.id(2000))
+            .createdOn(cols.createdOn())
+            .build();
     ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(ID, 2));
-    ps2.setUploader(new Account.Id(3000));
-    ps2.setRevision(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
-    ps2.setCreatedOn(cols.lastUpdatedOn());
+    PatchSet ps2 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 2))
+            .commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
+            .uploader(Account.id(3000))
+            .createdOn(cols.lastUpdatedOn())
+            .build();
     ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
     assertRoundTrip(
-        newBuilder()
-            .patchSets(ImmutableMap.of(ps2.getId(), ps2, ps1.getId(), ps1).entrySet())
-            .build(),
+        newBuilder().patchSets(ImmutableMap.of(ps2.id(), ps2, ps1.id(), ps1).entrySet()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -367,28 +348,31 @@
   @Test
   public void serializeApprovals() throws Exception {
     PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2001), new LabelId("Code-Review")),
-            (short) 1,
-            new Timestamp(1212L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+            .value(1)
+            .granted(new Timestamp(1212L))
+            .build();
     ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a1Bytes.size()).isEqualTo(43);
 
     PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2002), new LabelId("Verified")),
-            (short) -1,
-            new Timestamp(3434L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+            .value(-1)
+            .granted(new Timestamp(3434L))
+            .build();
     ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
     assertRoundTrip(
         newBuilder()
-            .approvals(
-                ImmutableListMultimap.of(a2.getPatchSetId(), a2, a1.getPatchSetId(), a1).entries())
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -406,11 +390,8 @@
             .reviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -503,11 +484,8 @@
             .pendingReviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -564,9 +542,7 @@
   @Test
   public void serializeAllPastReviewers() throws Exception {
     assertRoundTrip(
-        newBuilder()
-            .allPastReviewers(ImmutableList.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
+        newBuilder().allPastReviewers(ImmutableList.of(Account.id(2002), Account.id(2001))).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -584,13 +560,13 @@
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
                         new Timestamp(1212L),
-                        new Account.Id(1000),
-                        new Account.Id(2002),
+                        Account.id(1000),
+                        Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
                         new Timestamp(3434L),
-                        new Account.Id(1000),
-                        new Account.Id(2001),
+                        Account.id(1000),
+                        Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -613,6 +589,35 @@
   }
 
   @Test
+  public void serializeAssigneeUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .assigneeUpdates(
+                ImmutableList.of(
+                    AssigneeStatusUpdate.create(
+                        new Timestamp(1212L), Account.id(1000), Optional.of(Account.id(2001))),
+                    AssigneeStatusUpdate.create(
+                        new Timestamp(3434L), Account.id(1000), Optional.empty())))
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .addAssigneeUpdate(
+                AssigneeStatusUpdateProto.newBuilder()
+                    .setDate(1212L)
+                    .setUpdatedBy(1000)
+                    .setCurrentAssignee(2001)
+                    .setHasCurrentAssignee(true))
+            .addAssigneeUpdate(
+                AssigneeStatusUpdateProto.newBuilder()
+                    .setDate(3434L)
+                    .setUpdatedBy(1000)
+                    .setHasCurrentAssignee(false))
+            .build());
+  }
+
+  @Test
   public void serializeSubmitRecords() throws Exception {
     SubmitRecord sr1 = new SubmitRecord();
     sr1.status = SubmitRecord.Status.OK;
@@ -635,19 +640,19 @@
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid1"),
-            new Account.Id(1000),
+            ChangeMessage.key(ID, "uuid1"),
+            Account.id(1000),
             new Timestamp(1212L),
-            new PatchSet.Id(ID, 1));
+            PatchSet.id(ID, 1));
     ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid2"),
-            new Account.Id(2000),
+            ChangeMessage.key(ID, "uuid2"),
+            Account.id(2000),
             new Timestamp(3434L),
-            new PatchSet.Id(ID, 2));
+            PatchSet.id(ID, 2));
     ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
@@ -668,31 +673,30 @@
     Comment c1 =
         new Comment(
             new Comment.Key("uuid1", "file1", 1),
-            new Account.Id(1001),
+            Account.id(1001),
             new Timestamp(1212L),
             (short) 1,
             "message 1",
             "serverId",
             false);
-    c1.setRevId(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
+    c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
     Comment c2 =
         new Comment(
             new Comment.Key("uuid2", "file2", 2),
-            new Account.Id(1002),
+            Account.id(1002),
             new Timestamp(3434L),
             (short) 2,
             "message 2",
             "serverId",
             true);
-    c2.setRevId(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
+    c2.setCommitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
     String c2Json = Serializer.GSON.toJson(c2);
 
     assertRoundTrip(
         newBuilder()
-            .publishedComments(
-                ImmutableListMultimap.of(new RevId(c2.revId), c2, new RevId(c1.revId), c1))
+            .publishedComments(ImmutableListMultimap.of(c2.getCommitId(), c2, c1.getCommitId(), c1))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -704,14 +708,26 @@
   }
 
   @Test
+  public void serializeUpdateCount() throws Exception {
+    assertRoundTrip(
+        newBuilder().updateCount(234).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .setUpdateCount(234)
+            .build());
+  }
+
+  @Test
   public void changeNotesStateMethods() throws Exception {
     assertThatSerializedClass(ChangeNotesState.class)
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("metaId", ObjectId.class)
                 .put("changeId", Change.Id.class)
+                .put("serverId", String.class)
                 .put("columns", ChangeColumns.class)
-                .put("pastAssignees", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType())
                 .put("hashtags", new TypeLiteral<ImmutableSet<String>>() {}.getType())
                 .put(
                     "patchSets",
@@ -728,11 +744,15 @@
                 .put(
                     "reviewerUpdates",
                     new TypeLiteral<ImmutableList<ReviewerStatusUpdate>>() {}.getType())
+                .put(
+                    "assigneeUpdates",
+                    new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<RevId, Comment>>() {}.getType())
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                .put("updateCount", int.class)
                 .build());
   }
 
@@ -751,7 +771,6 @@
                 .put("topic", String.class)
                 .put("originalSubject", String.class)
                 .put("submissionId", String.class)
-                .put("assignee", Account.Id.class)
                 .put("status", Change.Status.class)
                 .put("isPrivate", boolean.class)
                 .put("workInProgress", boolean.class)
@@ -764,36 +783,37 @@
   @Test
   public void patchSetFields() throws Exception {
     assertThatSerializedClass(PatchSet.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("id", PatchSet.Id.class)
-                .put("revision", RevId.class)
+                .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
                 .put("createdOn", Timestamp.class)
-                .put("groups", String.class)
-                .put("pushCertificate", String.class)
-                .put("description", String.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 
   @Test
   public void patchSetApprovalFields() throws Exception {
     assertThatSerializedClass(PatchSetApproval.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("patchSetId", PatchSet.Id.class)
                 .put("accountId", Account.Id.class)
-                .put("categoryId", LabelId.class)
+                .put("labelId", LabelId.class)
                 .build());
     assertThatSerializedClass(PatchSetApproval.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
                 .put("value", short.class)
                 .put("granted", Timestamp.class)
-                .put("tag", String.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
 
@@ -832,6 +852,19 @@
   }
 
   @Test
+  public void assigneeStatusUpdateMethods() throws Exception {
+    assertThatSerializedClass(AssigneeStatusUpdate.class)
+        .hasAutoValueMethods(
+            ImmutableMap.of(
+                "date",
+                Timestamp.class,
+                "updatedBy",
+                Account.Id.class,
+                "currentAssignee",
+                new TypeLiteral<Optional<Account.Id>>() {}.getType()));
+  }
+
+  @Test
   public void submitRecordFields() throws Exception {
     assertThatSerializedClass(SubmitRecord.class)
         .hasFields(
@@ -861,7 +894,7 @@
   @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
-        .hasFields(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
+        .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
     assertThatSerializedClass(ChangeMessage.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
@@ -905,10 +938,22 @@
                 .put("revId", String.class)
                 .put("serverId", String.class)
                 .put("unresolved", boolean.class)
-                .put("legacyFormat", boolean.class)
                 .build());
   }
 
+  @Test
+  public void serializeServerId() throws Exception {
+    assertRoundTrip(
+        newBuilder().serverId(DEFAULT_SERVER_ID).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setServerId(DEFAULT_SERVER_ID)
+            .setHasServerId(true)
+            .setColumns(colsProto.toBuilder())
+            .build());
+  }
+
   private static ChangeNotesStateProto toProto(ChangeNotesState state) throws Exception {
     return ChangeNotesStateProto.parseFrom(Serializer.INSTANCE.serialize(state));
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index a6c0224..145e914 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -16,15 +16,17 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -35,18 +37,17 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
@@ -73,7 +74,6 @@
   @Inject private DraftCommentNotes.Factory draftNotesFactory;
 
   @Inject private ChangeNoteJson changeNoteJson;
-  @Inject private LegacyChangeNoteRead legacyChangeNoteRead;
 
   @Inject private @GerritServerId String serverId;
 
@@ -101,7 +101,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
 
     description = "new, now more descriptive!";
     update = newUpdate(c, changeOwner);
@@ -109,7 +109,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
   }
 
   @Test
@@ -119,7 +119,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putComment(
-        Status.PUBLISHED,
+        Comment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -131,14 +131,14 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setTag(tag);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -162,7 +162,7 @@
 
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
-    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
+    assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
 
   @Test
@@ -181,7 +181,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
     update.putComment(
-        Status.PUBLISHED,
+        Comment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -193,7 +193,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setChangeMessage("coverage verification");
     update.setTag(coverageTag);
@@ -209,10 +209,10 @@
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
-    assertThat(approval.getTag()).isEqualTo(integrationTag);
-    assertThat(approval.getValue()).isEqualTo(-1);
+    assertThat(approval.tag()).hasValue(integrationTag);
+    assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -236,17 +236,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(psas.get(0).getGranted());
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
 
   @Test
@@ -268,18 +268,18 @@
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
-    assertThat(psa1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(psa1.getAccountId().get()).isEqualTo(1);
-    assertThat(psa1.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa1.getValue()).isEqualTo((short) -1);
-    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psa1.patchSetId()).isEqualTo(ps1);
+    assertThat(psa1.accountId().get()).isEqualTo(1);
+    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.value()).isEqualTo((short) -1);
+    assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
-    assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
-    assertThat(psa2.getAccountId().get()).isEqualTo(1);
-    assertThat(psa2.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
+    assertThat(psa2.patchSetId()).isEqualTo(ps2);
+    assertThat(psa2.accountId().get()).isEqualTo(1);
+    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.value()).isEqualTo((short) +1);
+    assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
 
   @Test
@@ -292,8 +292,8 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) -1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
@@ -301,8 +301,8 @@
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) 1);
   }
 
   @Test
@@ -321,17 +321,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(2);
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
 
   @Test
@@ -344,9 +344,9 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.accountId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
@@ -356,8 +356,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
   }
 
   @Test
@@ -370,9 +374,9 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
@@ -382,8 +386,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -392,9 +400,9 @@
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 2);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -408,17 +416,17 @@
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
         notes.getApprovals().get(c.currentPatchSetId()).stream()
-            .sorted(comparing(a -> a.getAccountId().get()))
+            .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
 
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
   @Test
@@ -448,12 +456,12 @@
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
-    assertThat(approvals.get(1).isPostSubmit()).isTrue();
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).postSubmit()).isTrue();
   }
 
   @Test
@@ -488,26 +496,26 @@
 
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(3);
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo(1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo(2);
-    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
-    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
-    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
-    assertThat(approvals.get(2).getValue()).isEqualTo(2);
-    assertThat(approvals.get(2).isPostSubmit()).isTrue();
+    assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo(1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo(2);
+    assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).value()).isEqualTo(2);
+    assertThat(approvals.get(2).postSubmit()).isTrue();
   }
 
   @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -516,8 +524,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(REVIEWER, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(REVIEWER, Account.id(2), ts)
                     .build()));
   }
 
@@ -525,8 +533,8 @@
   public void reviewerTypes() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -535,8 +543,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(CC, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(CC, Account.id(2), ts)
                     .build()));
   }
 
@@ -544,29 +552,29 @@
   public void oneReviewerMultipleTypes() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     Timestamp ts = new Timestamp(update.getWhen().getTime());
     assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
     update = newUpdate(c, otherUser);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
 
     notes = newNotes(c);
     ts = new Timestamp(update.getWhen().getTime());
     assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
 
   @Test
   public void removeReviewer() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     update = newUpdate(c, changeOwner);
@@ -580,17 +588,17 @@
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
 
     update = newUpdate(c, changeOwner);
-    update.removeReviewer(otherUser.getAccount().getId());
+    update.removeReviewer(otherUser.getAccount().id());
     update.commit();
 
     notes = newNotes(c);
     psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
@@ -736,6 +744,35 @@
   }
 
   @Test
+  public void assigneeStatusUpdateChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeAssignee();
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    ImmutableList<AssigneeStatusUpdate> statusUpdates = notes.getAssigneeUpdates();
+    assertThat(statusUpdates).hasSize(4);
+    assertThat(statusUpdates.get(3).updatedBy()).isEqualTo(otherUserId);
+    assertThat(statusUpdates.get(3).currentAssignee()).hasValue(otherUserId);
+    assertThat(statusUpdates.get(2).currentAssignee()).isEmpty();
+    assertThat(statusUpdates.get(1).currentAssignee()).hasValue(changeOwner.getAccountId());
+    assertThat(statusUpdates.get(0).currentAssignee()).hasValue(otherUserId);
+  }
+
+  @Test
   public void hashtagCommit() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -819,14 +856,17 @@
 
     // Trying to set another Change-Id fails
     String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
-    update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(
-        "The Change-Id was already set to "
-            + c.getKey()
-            + ", so we cannot set this Change-Id: "
-            + otherChangeId);
-    update.setChangeId(otherChangeId);
+    ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> failingUpdate.setChangeId(otherChangeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The Change-Id was already set to "
+                + c.getKey()
+                + ", so we cannot set this Change-Id: "
+                + otherChangeId);
   }
 
   @Test
@@ -834,7 +874,7 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
+    BranchNameKey expectedBranch = BranchNameKey.create(project, "refs/heads/master");
     assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
 
     // An update doesn't affect the branch
@@ -849,7 +889,7 @@
     update.setBranch(otherBranch);
     update.commit();
     assertThat(newNotes(c).getChange().getDest())
-        .isEqualTo(new Branch.NameKey(project, otherBranch));
+        .isEqualTo(BranchNameKey.create(project, otherBranch));
   }
 
   @Test
@@ -956,7 +996,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -968,7 +1008,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
     update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     notes = newNotes(c);
@@ -977,7 +1017,7 @@
 
   @Test
   public void commitChangeNotesUnique() throws Exception {
-    // PatchSetId -> RevId must be a one to one mapping
+    // PatchSetId -> ObjectId must be a one to one mapping
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
@@ -990,19 +1030,15 @@
     update.setCommit(rw, commit);
     update.commit();
 
-    try {
-      newNotes(c);
-      fail("Expected IOException");
-    } catch (StorageException e) {
-      assertCause(
-          e,
-          ConfigInvalidException.class,
-          "Multiple revisions parsed for patch set 1:"
-              + " RevId{"
-              + commit.name()
-              + "} and "
-              + ps.getRevision().get());
-    }
+    StorageException e = assertThrows(StorageException.class, () -> newNotes(c));
+    assertCause(
+        e,
+        ConfigInvalidException.class,
+        "Multiple revisions parsed for patch set 1:"
+            + " "
+            + commit.name()
+            + " and "
+            + ps.commitId().name());
   }
 
   @Test
@@ -1012,32 +1048,32 @@
     // ps1 created by newChange()
     ChangeNotes notes = newNotes(c);
     PatchSet ps1 = notes.getCurrentPatchSet();
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.id());
     assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
     assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
-    assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
+    assertThat(ps1.id()).isEqualTo(PatchSet.id(c.getId(), 1));
+    assertThat(ps1.uploader()).isEqualTo(changeOwner.getAccountId());
 
     // ps2 by other user
     RevCommit commit = incrementPatchSet(c, otherUser);
     notes = newNotes(c);
     PatchSet ps2 = notes.getCurrentPatchSet();
-    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
+    assertThat(ps2.id()).isEqualTo(PatchSet.id(c.getId(), 2));
     assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
     assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
-    assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
-    assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
-    assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
-    assertThat(ps2.getCreatedOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
+    assertThat(ps2.commitId()).isNotEqualTo(ps1.commitId());
+    assertThat(ps2.commitId()).isEqualTo(commit);
+    assertThat(ps2.uploader()).isEqualTo(otherUser.getAccountId());
+    assertThat(ps2.createdOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // comment on ps1, current patch set is still ps2
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(ps1.getId());
+    update.setPatchSetId(ps1.id());
     update.setChangeMessage("Comment on old patch set.");
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
   }
 
   @Test
@@ -1053,7 +1089,7 @@
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
-        Status.PUBLISHED,
+        Comment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -1065,7 +1101,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
@@ -1098,14 +1134,14 @@
     PatchSet.Id psId1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
+    assertThat(notes.getPatchSets().get(psId1).groups()).isEmpty();
 
     // ps1
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId1).groups()).containsExactly("a", "b").inOrder();
 
     incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
@@ -1114,8 +1150,8 @@
     update.setGroups(ImmutableList.of("d"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).getGroups()).containsExactly("d");
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId2).groups()).containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).groups()).containsExactly("a", "b").inOrder();
   }
 
   @Test
@@ -1144,8 +1180,8 @@
     readNote(notes, commit);
 
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isEmpty();
 
     // comment on ps2
@@ -1153,7 +1189,7 @@
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
     update.putComment(
-        Status.PUBLISHED,
+        Comment.Status.PUBLISHED,
         newComment(
             psId2,
             "a.txt",
@@ -1165,15 +1201,15 @@
             ts,
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
     notes = newNotes(c);
 
     patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
   }
 
@@ -1203,13 +1239,13 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
+    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 2);
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -1235,10 +1271,10 @@
               time1,
               message1,
               (short) 0,
-              "abcd1234abcd1234abcd1234abcd1234abcd1234",
+              ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
-      update1.putComment(Status.PUBLISHED, comment1);
+      update1.putComment(Comment.Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1260,12 +1296,7 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithComments =
           new ChangeNotesParser(
-              c.getId(),
-              commitWithComments.copy(),
-              rw,
-              changeNoteJson,
-              legacyChangeNoteRead,
-              args.metrics);
+              c.getId(), commitWithComments.copy(), rw, changeNoteJson, args.metrics);
       ChangeNotesState state = notesWithComments.parseAll();
       assertThat(state.approvals()).isEmpty();
       assertThat(state.publishedComments()).hasSize(1);
@@ -1274,12 +1305,7 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithApprovals =
           new ChangeNotesParser(
-              c.getId(),
-              commitWithApprovals.copy(),
-              rw,
-              changeNoteJson,
-              legacyChangeNoteRead,
-              args.metrics);
+              c.getId(), commitWithApprovals.copy(), rw, changeNoteJson, args.metrics);
 
       ChangeNotesState state = notesWithApprovals.parseAll();
       assertThat(state.approvals()).hasSize(1);
@@ -1317,25 +1343,25 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.getLabel()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo("Verified");
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo("Code-Review");
   }
 
   @Test
   public void changeMessageOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("Just a little code change.\n");
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm.getMessage()).isEqualTo("Just a little code change.\n");
-    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.getPatchSetId()).isEqualTo(c.currentPatchSetId());
   }
 
@@ -1343,7 +1369,7 @@
   public void noChangeMessage() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1360,7 +1386,7 @@
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
@@ -1379,14 +1405,14 @@
                 + "Testing paragraph 2\n"
                 + "\n"
                 + "Testing paragraph 3");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
   public void changeMessagesMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("This is the change message for the first PS.");
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
@@ -1404,12 +1430,12 @@
     ChangeMessage cm1 = notes.getChangeMessages().get(0);
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
 
     ChangeMessage cm2 = notes.getChangeMessages().get(1);
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
-    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
 
@@ -1417,14 +1443,14 @@
   public void changeMessageMultipleInOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("First change message.\n");
     update.commit();
 
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("Second change message.\n");
     update.commit();
 
@@ -1433,10 +1459,10 @@
     List<ChangeMessage> cm = notes.getChangeMessages();
     assertThat(cm).hasSize(2);
     assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
-    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.get(0).getPatchSetId()).isEqualTo(ps1);
     assertThat(cm.get(1).getMessage()).isEqualTo("Second change message.\n");
-    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.get(1).getPatchSetId()).isEqualTo(ps1);
   }
 
@@ -1445,7 +1471,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -1459,14 +1485,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1474,7 +1500,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
     Comment comment =
@@ -1489,14 +1515,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1504,7 +1530,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
     Comment comment =
@@ -1519,14 +1545,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1534,7 +1560,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
     Comment comment =
@@ -1549,18 +1575,18 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
-  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameCommitId() throws Exception {
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
     incrementPatchSet(c);
@@ -1574,7 +1600,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
     Timestamp time = TimeUtil.nowTs();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment1 =
         newComment(
@@ -1588,7 +1614,7 @@
             time,
             message1,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -1602,7 +1628,7 @@
             time,
             message2,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment3 =
         newComment(
@@ -1616,23 +1642,23 @@
             time,
             message3,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment3);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getComments())
         .isEqualTo(
             ImmutableListMultimap.of(
-                revId, comment1,
-                revId, comment2,
-                revId, comment3));
+                commitId, comment1,
+                commitId, comment2,
+                commitId, comment3));
   }
 
   @Test
@@ -1645,7 +1671,7 @@
     CommentRange range = new CommentRange(1, 1, 2, 1);
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -1659,25 +1685,25 @@
             time,
             message,
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(3), TimeUtil.nowTs());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
-    accountCache.put(account);
-    IdentifiedUser user = userFactory.create(account.getId());
+    accountCache.put(account.build());
+    IdentifiedUser user = userFactory.create(Account.id(3));
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, user);
@@ -1698,16 +1724,16 @@
             time,
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     assertThat(notes.getComments())
-        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
+        .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
   @Test
@@ -1716,8 +1742,8 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -1736,10 +1762,10 @@
             now,
             messageForBase,
             (short) 0,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, commentForBase);
+    update.putComment(Comment.Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -1755,17 +1781,17 @@
             now,
             messageForPS,
             (short) 1,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, commentForPS);
+    update.putComment(Comment.Status.PUBLISHED, commentForPS);
     update.commit();
 
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), commentForBase,
-                new RevId(rev2), commentForPS));
+                commitId1, commentForBase,
+                commitId2, commentForPS));
   }
 
   @Test
@@ -1773,7 +1799,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -1794,10 +1820,10 @@
             timeForComment1,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -1813,17 +1839,17 @@
             timeForComment2,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -1831,7 +1857,7 @@
   public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename1 = "filename1";
@@ -1852,10 +1878,10 @@
             now,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -1871,17 +1897,17 @@
             now,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -1889,8 +1915,8 @@
   public void patchLineCommentMultiplePatchsets() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -1910,10 +1936,10 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1933,24 +1959,24 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), comment1,
-                new RevId(rev2), comment2));
+                commitId1, comment1,
+                commitId2, comment2));
   }
 
   @Test
   public void patchLineCommentSingleDraftToPublished() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -1970,26 +1996,26 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -1997,7 +2023,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2020,7 +2046,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -2034,32 +2060,32 @@
             now,
             "other on ps1",
             side,
-            rev,
+            commitId,
             false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
+    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
     assertThat(notes.getComments()).isEmpty();
 
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -2067,8 +2093,8 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2090,7 +2116,7 @@
             now,
             "comment on base",
             (short) 0,
-            rev1,
+            commitId1,
             false);
     Comment psComment =
         newComment(
@@ -2104,27 +2130,27 @@
             now,
             "comment on ps",
             (short) 1,
-            rev2,
+            commitId2,
             false);
 
-    update.putComment(Status.DRAFT, baseComment);
-    update.putComment(Status.DRAFT, psComment);
+    update.putComment(Comment.Status.DRAFT, baseComment);
+    update.putComment(Comment.Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
     assertThat(notes.getComments()).isEmpty();
 
     // Publish both comments.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    update.putComment(Status.PUBLISHED, baseComment);
-    update.putComment(Status.PUBLISHED, psComment);
+    update.putComment(Comment.Status.PUBLISHED, baseComment);
+    update.putComment(Comment.Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
@@ -2132,16 +2158,15 @@
     assertThat(notes.getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
   }
 
   @Test
   public void patchLineCommentsDeleteAllDrafts() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    ObjectId objId = ObjectId.fromString(rev);
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -2161,15 +2186,15 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.DRAFT, comment);
+    update.putComment(Comment.Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
+    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(commitId)).isTrue();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
@@ -2185,10 +2210,8 @@
   public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    ObjectId objId1 = ObjectId.fromString(rev1);
-    ObjectId objId2 = ObjectId.fromString(rev2);
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2208,10 +2231,10 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2231,10 +2254,10 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Status.DRAFT, comment2);
+    update.putComment(Comment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2248,15 +2271,15 @@
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
     NoteMap noteMap = notes.getDraftCommentNotes().getNoteMap();
-    assertThat(noteMap.contains(objId1)).isTrue();
-    assertThat(noteMap.contains(objId2)).isFalse();
+    assertThat(noteMap.contains(commitId1)).isTrue();
+    assertThat(noteMap.contains(commitId2)).isFalse();
   }
 
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2276,9 +2299,9 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2289,7 +2312,7 @@
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
     Change c = newChange();
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2309,9 +2332,9 @@
             now,
             "draft comment on ps1",
             side,
-            rev,
+            commitId,
             false);
-    update.putComment(Status.DRAFT, draft);
+    update.putComment(Comment.Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2331,9 +2354,9 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
-    update.putComment(Status.PUBLISHED, pub);
+    update.putComment(Comment.Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2344,7 +2367,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2361,14 +2384,14 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -2376,7 +2399,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2393,22 +2416,22 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void putCommentsForMultipleRevisions() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2432,7 +2455,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2446,10 +2469,10 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
+    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2458,8 +2481,8 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
@@ -2470,7 +2493,7 @@
   @Test
   public void publishSubsetOfCommentsOnRevision() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2490,7 +2513,7 @@
             now,
             "comment1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2504,24 +2527,25 @@
             now,
             "comment2",
             side,
-            rev1.get(),
+            commitId1,
             false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
+    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1, comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1))
+        .containsExactly(comment1, comment2);
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2535,15 +2559,15 @@
     assertThat(msg.getMessage()).isEqualTo("A message.");
     assertThat(msg.getAuthor()).isNull();
 
-    update = newUpdate(c, internalUser);
-    exception.expect(IllegalStateException.class);
-    update.putApproval("Code-Review", (short) 1);
+    ChangeUpdate failingUpdate = newUpdate(c, internalUser);
+    assertThrows(
+        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
   }
 
   @Test
   public void filterOutAndFixUpZombieDraftComments() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2562,7 +2586,7 @@
             now,
             "comment on ps1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2576,10 +2600,10 @@
             now,
             "another comment",
             side,
-            rev1.get(),
+            commitId1,
             false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
+    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(Comment.Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2587,7 +2611,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Comment.Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2604,16 +2628,16 @@
 
     // Looking at drafts directly shows the zombie comment.
     DraftCommentNotes draftNotes = draftNotesFactory.create(c.getId(), otherUserId);
-    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
+    assertThat(draftNotes.load().getComments().get(commitId1)).containsExactly(comment1, comment2);
 
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Comment.Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2624,7 +2648,7 @@
   public void updateCommentsInSequentialUpdates() throws Exception {
     Change c = newChange();
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
     Comment comment1 =
@@ -2639,9 +2663,9 @@
             new Timestamp(update1.getWhen().getTime()),
             "comment 1",
             (short) 1,
-            rev,
+            commitId,
             false);
-    update1.putComment(Status.PUBLISHED, comment1);
+    update1.putComment(Comment.Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
     Comment comment2 =
@@ -2656,9 +2680,9 @@
             new Timestamp(update2.getWhen().getTime()),
             "comment 2",
             (short) 1,
-            rev,
+            commitId,
             false);
-    update2.putComment(Status.PUBLISHED, comment2);
+    update2.putComment(Comment.Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2667,7 +2691,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(new RevId(rev));
+    List<Comment> comments = notes.getComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -2697,7 +2721,7 @@
     int numComments = notes.getComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
     update.putApproval("Code-Review", (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -2713,9 +2737,9 @@
             new Timestamp(update.getWhen().getTime()),
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
-    update.putComment(Status.PUBLISHED, comment);
+    update.putComment(Comment.Status.PUBLISHED, comment);
     update.commit();
 
     notes = newNotes(c);
@@ -2734,7 +2758,7 @@
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setCurrentPatchSet();
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
@@ -2751,7 +2775,7 @@
 
     // Delete PS1, PS2 becomes current.
     update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setPatchSetState(PatchSetState.DELETED);
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
@@ -2930,8 +2954,8 @@
   public void pendingReviewers() throws Exception {
     Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
     Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
-    Account.Id ownerId = changeOwner.getAccount().getId();
-    Account.Id otherUserId = otherUser.getAccount().getId();
+    Account.Id ownerId = changeOwner.getAccount().id();
+    Account.Id otherUserId = otherUser.getAccount().id();
 
     ChangeNotes notes = newNotes(newChange());
     assertThat(notes.getPendingReviewers().asTable()).isEmpty();
@@ -3006,9 +3030,9 @@
   public void setRevertOfToCurrentChangeFails() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("A change cannot revert itself");
-    update.setRevertOf(c.getId().get());
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> update.setRevertOf(c.getId().get()));
+    assertThat(thrown).hasMessageThat().contains("A change cannot revert itself");
   }
 
   @Test
@@ -3016,9 +3040,58 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setRevertOf(newChange().getId().get());
-    exception.expect(StorageException.class);
-    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
+    StorageException thrown = assertThrows(StorageException.class, () -> update.commit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Given ChangeUpdate is only allowed on initial commit");
+  }
+
+  @Test
+  public void updateCount() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
     update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
+  }
+
+  @Test
+  public void createPatchSetAfterPatchSetDeletion() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    // Create PS2.
+    incrementCurrentPatchSetFieldOnly(c);
+    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.setGroups(ImmutableList.of(commit.name()));
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
+
+    // Delete PS2.
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+    c = newNotes(c).getChange();
+    assertThat(c.currentPatchSetId().get()).isEqualTo(1);
+
+    // Create another PS2
+    incrementCurrentPatchSetFieldOnly(c);
+    commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.PUBLISHED);
+    update.setCommit(rw, commit);
+    update.setGroups(ImmutableList.of(commit.name()));
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
   }
 
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
@@ -3042,11 +3115,11 @@
         break;
       }
     }
-    assertThat(cause)
-        .named(
+    assertWithMessage(
             expectedClass.getSimpleName()
                 + " in causal chain of:\n"
                 + Throwables.getStackTraceAsString(e))
+        .that(cause)
         .isNotNull();
     assertThat(cause.getMessage()).isEqualTo(expectedMsg);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 4dd2005..c2620dc 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -16,20 +16,20 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import java.sql.Timestamp;
 import java.time.ZonedDateTime;
 import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-public class CommentTimestampAdapterTest extends GerritBaseTests {
+public class CommentTimestampAdapterTest {
   /** Arbitrary time outside of a DST transition, as an ISO instant. */
   private static final String NON_DST_STR = "2017-02-07T10:20:30.123Z";
 
@@ -153,14 +153,14 @@
     Comment c =
         new Comment(
             new Comment.Key("uuid", "filename", 1),
-            new Account.Id(100),
+            Account.id(100),
             NON_DST_TS,
             (short) 0,
             "message",
             "serverId",
             false);
     c.lineNbr = 1;
-    c.revId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    c.setCommitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
 
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5552572..97781a4 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -19,9 +19,9 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -44,8 +44,8 @@
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.putApproval("Verified", (short) 1);
     update.putApproval("Code-Review", (short) -1);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
@@ -199,10 +199,13 @@
 
   @Test
   public void anonymousUser() throws Exception {
-    Account anon = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account anon =
+        Account.builder(Account.id(3), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
     accountCache.put(anon);
     Change c = newChange();
-    ChangeUpdate update = newUpdate(c, userFactory.create(anon.getId()));
+    ChangeUpdate update = newUpdate(c, userFactory.create(anon.id()));
     update.setChangeMessage("Comment on the change.");
     update.commit();
 
@@ -241,7 +244,7 @@
   public void noChangeMessage() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.commit();
 
     assertBodyEquals(
@@ -311,7 +314,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
@@ -332,7 +335,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
     update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
new file mode 100644
index 0000000..bf49884
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class DraftCommentNotesTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void createAndPublishCommentInOneAction_runsDraftOperationAsynchronously()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndPublishComment_runsPublishDraftOperationAsynchronously() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+    assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndDeleteDraftComment_runsDraftOperationSynchronously() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.deleteComment(comment(c.currentPatchSetId()));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(0);
+  }
+
+  private Comment comment(PatchSet.Id psId) {
+    return newComment(
+        psId,
+        "filename",
+        "uuid",
+        null,
+        0,
+        otherUser,
+        null,
+        TimeUtil.nowTs(),
+        "comment",
+        (short) 0,
+        ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
+        false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index 1cbe61d..333c229 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -44,7 +44,7 @@
 
   @Before
   public void setUp() throws Exception {
-    projectName = new Project.NameKey("repo");
+    projectName = Project.nameKey("repo");
     repo = new InMemoryRepository(new DfsRepositoryDescription(projectName.get()));
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
@@ -59,12 +59,7 @@
   public void parseNonBlob() throws Exception {
     String refName = "refs/foo/master";
     tr.branch(refName).commit().create();
-    try {
-      IntBlob.parse(repo, refName);
-      assert_().fail("Expected IncorrectObjectTypeException");
-    } catch (IncorrectObjectTypeException e) {
-      // Expected.
-    }
+    assertThrows(IncorrectObjectTypeException.class, () -> IntBlob.parse(repo, refName));
   }
 
   @Test
@@ -85,12 +80,9 @@
   public void parseInvalid() throws Exception {
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob("1 2 3"));
-    try {
-      IntBlob.parse(repo, refName);
-      assert_().fail("Expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e).hasMessageThat().isEqualTo("invalid value in refs/foo blob at " + id.name());
-    }
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> IntBlob.parse(repo, refName));
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid value in refs/foo blob at " + id.name());
   }
 
   @Test
@@ -180,19 +172,19 @@
   @Test
   public void storeWrongOldId() throws Exception {
     String refName = "refs/foo";
-    try {
-      IntBlob.store(
-          repo,
-          rw,
-          projectName,
-          refName,
-          ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
-          123,
-          GitReferenceUpdated.DISABLED);
-      assert_().fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      assertThat(e.getFailedRefs()).containsExactly("refs/foo");
-    }
+    LockFailureException thrown =
+        assertThrows(
+            LockFailureException.class,
+            () ->
+                IntBlob.store(
+                    repo,
+                    rw,
+                    projectName,
+                    refName,
+                    ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+                    123,
+                    GitReferenceUpdated.DISABLED));
+    assertThat(thrown.getFailedRefs()).containsExactly("refs/foo");
     assertThat(IntBlob.parse(repo, refName)).isEmpty();
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 74cc507..a768eaf 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -15,22 +15,27 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
 
+import com.github.rholder.retry.BlockStrategy;
 import com.github.rholder.retry.Retryer;
 import com.github.rholder.retry.RetryerBuilder;
 import com.github.rholder.retry.StopStrategies;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Expect;
 import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
-import java.util.concurrent.ExecutionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -41,11 +46,14 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 
-public class RepoSequenceTest extends GerritBaseTests {
+public class RepoSequenceTest {
+  @Rule public final Expect expect = Expect.create();
+
   // Don't sleep in tests.
-  private static final Retryer<RefUpdate> RETRYER =
+  private static final Retryer<ImmutableList<Integer>> RETRYER =
       RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
 
   private InMemoryRepositoryManager repoManager;
@@ -54,7 +62,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("project");
+    project = Project.nameKey("project");
     repoManager.createRepository(project);
   }
 
@@ -66,13 +74,13 @@
       RepoSequence s = newSequence(name, 1, batchSize);
       for (int i = 1; i <= max; i++) {
         try {
-          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
+          assertWithMessage("i=" + i + " for " + name).that(s.next()).isEqualTo(i);
         } catch (StorageException e) {
           throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
         }
       }
-      assertThat(s.acquireCount)
-          .named("acquireCount for " + name)
+      assertWithMessage("acquireCount for " + name)
+          .that(s.acquireCount)
           .isEqualTo(divCeil(max, batchSize));
     }
   }
@@ -160,7 +168,7 @@
     RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
     assertThat(doneBgUpdate.get()).isFalse();
     assertThat(s.next()).isEqualTo(1234);
-    // Single acquire call that results in 2 ref reads.
+    // Two acquire calls, but only one successful.
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(doneBgUpdate.get()).isTrue();
   }
@@ -168,9 +176,11 @@
   @Test
   public void failOnInvalidValue() throws Exception {
     ObjectId id = writeBlob("id", "not a number");
-    exception.expect(StorageException.class);
-    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
-    newSequence("id", 1, 3).next();
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid value in refs/sequences/id blob at " + id.name());
   }
 
   @Test
@@ -178,13 +188,9 @@
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
-      try {
-        newSequence("id", 1, 3).next();
-        fail();
-      } catch (StorageException e) {
-        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
-        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
-      }
+      StorageException e =
+          assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+      assertThat(e.getCause()).isInstanceOf(IncorrectObjectTypeException.class);
     }
   }
 
@@ -197,12 +203,84 @@
             1,
             10,
             () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
-            RetryerBuilder.<RefUpdate>newBuilder()
+            RetryerBuilder.<ImmutableList<Integer>>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
-    exception.expect(StorageException.class);
-    exception.expectMessage("Failed to update refs/sequences/id: LOCK_FAILURE");
-    s.next();
+    StorageException thrown = assertThrows(StorageException.class, () -> s.next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to update refs/sequences/id: LOCK_FAILURE");
+  }
+
+  @Test
+  public void idCanBeRetrievedFromOtherThreadWhileWaitingToRetry() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    // Let the first update of the sequence fail with LOCK_FAILURE, so that the update is retried.
+    CountDownLatch lockFailure = new CountDownLatch(1);
+    CountDownLatch parallelSuccessfulSequenceGeneration = new CountDownLatch(1);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate =
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
+          }
+        };
+
+    BlockStrategy blockStrategy =
+        t -> {
+          // Keep blocking until we verified that another thread can retrieve a sequence number
+          // while we are blocking here.
+          lockFailure.countDown();
+          parallelSuccessfulSequenceGeneration.await();
+        };
+
+    // Use batch size = 1 to make each call go to NoteDb.
+    RepoSequence s =
+        newSequence(
+            "id",
+            1,
+            1,
+            bgUpdate,
+            RepoSequence.retryerBuilder().withBlockStrategy(blockStrategy).build());
+
+    assertThat(doneBgUpdate.get()).isFalse();
+
+    // Start a thread to get a sequence number. This thread needs to update the sequence in NoteDb,
+    // but due to the background update (see bgUpdate) the first attempt to update NoteDb fails
+    // with LOCK_FAILURE. RepoSequence uses a retryer to retry the NoteDb update on LOCK_FAILURE,
+    // but our block strategy ensures that this retry only happens after isBlocking was set to
+    // false.
+    Future<?> future =
+        Executors.newFixedThreadPool(1)
+            .submit(
+                () -> {
+                  // The background update sets the next available sequence number to 1234. Then the
+                  // test thread retrieves one sequence number, so that the next available sequence
+                  // number for this thread is 1235.
+                  expect.that(s.next()).isEqualTo(1235);
+                });
+
+    // Wait until the LOCK_FAILURE has happened and the block strategy was entered.
+    lockFailure.await();
+
+    // Verify that the background update was done now.
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    // Verify that we can retrieve a sequence number while the other thread is blocked. If the
+    // s.next() call hangs it means that the RepoSequence.counterLock was not released before the
+    // background thread started to block for retry. In this case the test would time out.
+    assertThat(s.next()).isEqualTo(1234);
+
+    // Stop blocking the retry of the background thread (and verify that it was still blocked).
+    parallelSuccessfulSequenceGeneration.countDown();
+
+    // Wait until the background thread is done.
+    future.get();
+
+    // Two successful acquire calls (because batch size == 1).
+    assertThat(s.acquireCount).isEqualTo(2);
   }
 
   @Test
@@ -260,7 +338,7 @@
       final int start,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate> retryer) {
+      Retryer<ImmutableList<Integer>> retryer) {
     return new RepoSequence(
         repoManager,
         GitReferenceUpdated.DISABLED,
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index 6c63c5f..52a81ad 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.jgit.diff.ReplaceEdit;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
 import org.junit.Test;
 
-public class IntraLineLoaderTest extends GerritBaseTests {
+public class IntraLineLoaderTest {
 
   @Test
   public void rewriteAtStartOfLineIsRecognized() throws Exception {
@@ -88,22 +88,30 @@
   // TODO: expected failure
   // the current code does not work on the first line
   // and the insert marker is in the wrong location
-  @Test(expected = AssertionError.class)
+  @Test
   public void preferInsertAtLineBreak2() throws Exception {
-    String a = "  abc\n    def\n";
-    String b = "    abc\n      def\n";
-    assertThat(intraline(a, b))
-        .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          String a = "  abc\n    def\n";
+          String b = "    abc\n      def\n";
+          assertThat(intraline(a, b))
+              .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
+        });
   }
 
   // TODO: expected failure
   // the current code does not work on the first line
-  @Test(expected = AssertionError.class)
+  @Test
   public void preferDeleteAtLineBreak() throws Exception {
-    String a = "    abc\n      def\n";
-    String b = "  abc\n    def\n";
-    assertThat(intraline(a, b))
-        .isEqualTo(ref().remove("  ").common("  abc\n").remove("  ").common("  def\n").edits);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          String a = "    abc\n      def\n";
+          String b = "  abc\n    def\n";
+          assertThat(intraline(a, b))
+              .isEqualTo(ref().remove("  ").common("  abc\n").remove("  ").common("  def\n").edits);
+        });
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
index 4ed5f4b..7ac1c31 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
@@ -19,11 +19,10 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Patch;
 import org.junit.Test;
 
-public class PatchListEntryTest extends GerritBaseTests {
+public class PatchListEntryTest {
   @Test
   public void empty1() {
     final String name = "empty-file";
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index ccdd040..e224191a 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -16,8 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Patch;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
@@ -26,7 +25,7 @@
 import java.util.Arrays;
 import org.junit.Test;
 
-public class PatchListTest extends GerritBaseTests {
+public class PatchListTest {
   @Test
   public void fileOrder() {
     String[] names = {
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index ff9ac41..305e81b 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -18,10 +18,9 @@
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class DefaultPermissionsMappingTest extends GerritBaseTests {
+public class DefaultPermissionsMappingTest {
   @Test
   public void stringToRefPermission() {
     assertThat(refPermission("doesnotexist")).isEmpty();
diff --git a/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
index f40c3bc..7aa73a7 100644
--- a/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
+++ b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
 
 import com.google.common.collect.ImmutableList;
@@ -30,8 +30,8 @@
         ImmutableList.of("plugin-foo-a", "plugin-foo-a-b");
 
     for (String permission : validPluginPermissions) {
-      assertThat(isValidPluginPermission(permission))
-          .named("valid plugin permission: %s", permission)
+      assertWithMessage("valid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
           .isTrue();
     }
   }
@@ -48,8 +48,8 @@
             "plugin-foo-a1");
 
     for (String permission : invalidPluginPermissions) {
-      assertThat(isValidPluginPermission(permission))
-          .named("invalid plugin permission: %s", permission)
+      assertWithMessage("invalid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
           .isFalse();
     }
   }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 58adc0c..43a3f10 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -15,268 +15,179 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+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.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.LABEL;
 import static com.google.gerrit.common.data.Permission.OWNER;
 import static com.google.gerrit.common.data.Permission.PUSH;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.ADMIN;
-import static com.google.gerrit.server.project.testing.Util.DEVS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.allowExclusive;
-import static com.google.gerrit.server.project.testing.Util.block;
-import static com.google.gerrit.server.project.testing.Util.deny;
-import static com.google.gerrit.server.project.testing.Util.doNotInherit;
-import static com.google.gerrit.testing.InMemoryRepositoryManager.newRepository;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-public class RefControlTest extends GerritBaseTests {
-  private void assertAdminsAreOwnersAndDevsAreNot() {
-    ProjectControl uBlah = user(local, DEVS);
-    ProjectControl uAdmin = user(local, DEVS, ADMIN);
+public class RefControlTest {
+  private static final AccountGroup.UUID ADMIN = AccountGroup.uuid("test.admin");
+  private static final AccountGroup.UUID DEVS = AccountGroup.uuid("test.devs");
 
-    assertThat(uBlah.isOwner()).named("not owner").isFalse();
-    assertThat(uAdmin.isOwner()).named("is owner").isTrue();
+  private void assertAdminsAreOwnersAndDevsAreNot() throws Exception {
+    ProjectControl uBlah = user(localKey, DEVS);
+    ProjectControl uAdmin = user(localKey, DEVS, ADMIN);
+
+    assertWithMessage("not owner").that(uBlah.isOwner()).isFalse();
+    assertWithMessage("is owner").that(uAdmin.isOwner()).isTrue();
   }
 
   private void assertOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
+    assertWithMessage("OWN " + ref).that(u.controlForRef(ref).isOwner()).isTrue();
   }
 
   private void assertNotOwner(ProjectControl u) {
-    assertThat(u.isOwner()).named("not owner").isFalse();
+    assertWithMessage("not owner").that(u.isOwner()).isFalse();
   }
 
   private void assertNotOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
+    assertWithMessage("NOT OWN " + ref).that(u.controlForRef(ref).isOwner()).isFalse();
   }
 
   private void assertCanAccess(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("can access").isTrue();
+    assertWithMessage("can access").that(access).isTrue();
   }
 
   private void assertAccessDenied(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("cannot access").isFalse();
+    assertWithMessage("cannot access").that(access).isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
+    assertWithMessage("can read " + ref).that(u.controlForRef(ref).isVisible()).isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
+    assertWithMessage("cannot read " + ref).that(u.controlForRef(ref).isVisible()).isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isTrue();
   }
 
   private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isFalse();
   }
 
   private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isTrue();
+    assertWithMessage("can upload").that(u.canPushToAtLeastOneRef()).isTrue();
   }
 
   private void assertCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("can create change " + ref).isTrue();
+    assertWithMessage("can create change " + ref).that(create).isTrue();
   }
 
   private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isFalse();
+    assertWithMessage("cannot upload").that(u.canPushToAtLeastOneRef()).isFalse();
   }
 
   private void assertCannotCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("cannot create change " + ref).isFalse();
+    assertWithMessage("cannot create change " + ref).that(create).isFalse();
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("can update " + ref).isTrue();
+    assertWithMessage("can update " + ref).that(update).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("cannot update " + ref).isFalse();
+    assertWithMessage("cannot update " + ref).that(update).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("can force push " + ref).isTrue();
+    assertWithMessage("can force push " + ref).that(update).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("cannot force push " + ref).isFalse();
+    assertWithMessage("cannot force push " + ref).that(update).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("can vote " + score).isTrue();
+    assertWithMessage("can vote " + score).that(range.contains(score)).isTrue();
   }
 
   private void assertCannotVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
+    assertWithMessage("cannot vote " + score).that(range.contains(score)).isFalse();
   }
 
-  private final AllProjectsName allProjectsName =
-      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
-  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-  private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
-  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
-  private Project.NameKey localKey = new Project.NameKey("local");
-  private ProjectConfig local;
-  private Project.NameKey parentKey = new Project.NameKey("parent");
-  private ProjectConfig parent;
-  private InMemoryRepositoryManager repoManager;
-  private ProjectCache projectCache;
-  private PermissionCollection.Factory sectionSorter;
-  private ChangeControl.Factory changeControlFactory;
+  private final AccountGroup.UUID fixers = AccountGroup.uuid("test.fixers");
+  private final Project.NameKey localKey = Project.nameKey("local");
+  private final Project.NameKey parentKey = Project.nameKey("parent");
 
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
+  @Inject private AllProjectsName allProjectsName;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject private ProjectCache projectCache;
+  @Inject private ProjectControl.Factory projectControlFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private SchemaCreator schemaCreator;
   @Inject private SingleVersionListener singleVersionListener;
   @Inject private ThreadLocalRequestContext requestContext;
-  @Inject private DefaultRefFilter.Factory refFilterFactory;
-  @Inject private TransferConfig transferConfig;
-  @Inject private MetricMaker metricMaker;
-  @Inject private ProjectConfig.Factory projectConfigFactory;
 
   @Before
   public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    projectCache =
-        new ProjectCache() {
-          @Override
-          public ProjectState getAllProjects() {
-            return get(allProjectsName);
-          }
-
-          @Override
-          public ProjectState getAllUsers() {
-            return null;
-          }
-
-          @Override
-          public ProjectState get(Project.NameKey projectName) {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project p) {}
-
-          @Override
-          public void remove(Project p) {}
-
-          @Override
-          public void remove(Project.NameKey name) {}
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> all() {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> byName(String prefix) {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public void onCreateProject(Project.NameKey newProjectName) {}
-
-          @Override
-          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project.NameKey p) {}
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName, boolean strict)
-              throws Exception {
-            return all.get(projectName);
-          }
-        };
-
     Injector injector = Guice.createInjector(new InMemoryModule());
     injector.injectMembers(this);
 
-    try {
-      Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects =
-          projectConfigFactory.create(new Project.NameKey(allProjectsName.get()));
-      allProjects.load(repo);
-      LabelType cr = Util.codeReview();
-      allProjects.getLabelSections().put(cr.getName(), cr);
-      add(allProjects);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
+    // Tests previously used ProjectConfig.Factory to create ProjectConfigs without going through
+    // the ProjectCache, which was wrong. Manually call getInstance so we don't store it in a
+    // field that is accessible to test methods.
+    ProjectConfig.Factory projectConfigFactory = injector.getInstance(ProjectConfig.Factory.class);
 
     singleVersionListener.start();
     try {
@@ -285,58 +196,80 @@
       singleVersionListener.stop();
     }
 
-    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
-        CacheBuilder.newBuilder().build();
-    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c), metricMaker);
+    // Clear out All-Projects and use the lowest-level API possible for project creation, so the
+    // only ACL entries are exactly what is initialized by this test, and we aren't subject to
+    // changing defaults in SchemaCreator or ProjectCreator.
+    try (Repository allProjectsRepo = repoManager.createRepository(allProjectsName);
+        TestRepository<Repository> tr = new TestRepository<>(allProjectsRepo)) {
+      tr.delete(REFS_CONFIG);
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjectsName)) {
+        ProjectConfig allProjectsConfig = projectConfigFactory.create(allProjectsName);
+        allProjectsConfig.load(md);
+        LabelType cr = TestLabels.codeReview();
+        allProjectsConfig.getLabelSections().put(cr.getName(), cr);
+        allProjectsConfig.commit(md);
+      }
+    }
 
-    parent = projectConfigFactory.create(parentKey);
-    parent.load(newRepository(parentKey));
-    add(parent);
-
-    local = projectConfigFactory.create(localKey);
-    local.load(newRepository(localKey));
-    add(local);
-    local.getProject().setParentName(parentKey);
+    repoManager.createRepository(parentKey).close();
+    repoManager.createRepository(localKey).close();
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(localKey)) {
+      ProjectConfig newLocal = projectConfigFactory.create(localKey);
+      newLocal.load(md);
+      newLocal.getProject().setParentName(parentKey);
+      newLocal.commit(md);
+    }
 
     requestContext.setContext(() -> null);
-
-    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
   }
 
   @After
-  public void tearDown() {
+  public void tearDown() throws Exception {
     requestContext.setContext(null);
   }
 
   @Test
   public void ownerProject() throws Exception {
-    allow(local, OWNER, ADMIN, "refs/*");
-
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
   public void denyOwnerProject() throws Exception {
-    allow(local, OWNER, ADMIN, "refs/*");
-    deny(local, OWNER, DEVS, "refs/*");
-
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(deny(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
   public void blockOwnerProject() throws Exception {
-    allow(local, OWNER, ADMIN, "refs/*");
-    block(local, OWNER, DEVS, "refs/*");
-
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(block(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
   public void branchDelegation1() throws Exception {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -349,12 +282,16 @@
 
   @Test
   public void branchDelegation2() throws Exception {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
-    allow(local, OWNER, fixers, "refs/heads/x/y/*");
-    doNotInherit(local, OWNER, "refs/heads/x/y/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .add(allow(OWNER).ref("refs/heads/x/y/*").group(fixers))
+        .setExclusiveGroup(permissionKey(OWNER).ref("refs/heads/x/y/*"), true)
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -363,7 +300,7 @@
     assertNotOwner("refs/*", uDev);
     assertNotOwner("refs/heads/master", uDev);
 
-    ProjectControl uFix = user(local, fixers);
+    ProjectControl uFix = user(localKey, fixers);
     assertNotOwner(uFix);
 
     assertOwner("refs/heads/x/y/*", uFix);
@@ -376,53 +313,41 @@
 
   @Test
   public void inheritRead_SingleBranchDeniesUpload() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
-    doNotInherit(local, READ, "refs/heads/foobar");
-    doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/heads/foobar"), true)
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/for/refs/heads/foobar"), true)
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCannotCreateChange("refs/heads/foobar", u);
   }
 
   @Test
-  public void blockPushDrafts() throws Exception {
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
-
-    ProjectControl u = user(local);
-    assertCreateChange("refs/heads/master", u);
-    assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH)).isFalse();
-  }
-
-  @Test
-  public void blockPushDraftsUnblockAdmin() throws Exception {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(parent, PUSH, ADMIN, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
-
-    ProjectControl u = user(local);
-    ProjectControl a = user(local, "a", ADMIN);
-
-    assertThat(a.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is allowed")
-        .isTrue();
-    assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is not allowed")
-        .isFalse();
-  }
-
-  @Test
   public void inheritRead_SingleBranchDoesNotOverrideInherited() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCreateChange("refs/heads/foobar", u);
@@ -430,31 +355,50 @@
 
   @Test
   public void inheritDuplicateSections() throws Exception {
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    assertCanAccess(user(local, "a", ADMIN));
-
-    local = projectConfigFactory.create(localKey);
-    local.load(newRepository(localKey));
-    local.getProject().setParentName(parentKey);
-    allow(local, READ, DEVS, "refs/*");
-    assertCanAccess(user(local, "d", DEVS));
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .update();
+    assertCanAccess(user(localKey, "a", ADMIN));
+    assertCanAccess(user(localKey, "d", DEVS));
   }
 
   @Test
   public void inheritRead_OverrideWithDeny() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    assertAccessDenied(user(local));
+    assertAccessDenied(user(localKey));
   }
 
   @Test
   public void inheritRead_AppendWithDenyOfRef() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
@@ -463,11 +407,19 @@
 
   @Test
   public void inheritRead_OverridesAndDeniesOfRef() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
@@ -476,11 +428,19 @@
 
   @Test
   public void inheritSubmit_OverridesAndDeniesOfRef() throws Exception {
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
-    deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotSubmit("refs/foobar", u);
     assertCannotSubmit("refs/tags/foobar", u);
     assertCanSubmit("refs/heads/foobar", u);
@@ -488,46 +448,73 @@
 
   @Test
   public void cannotUploadToAnyRef() throws Exception {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/*").group(DEVS))
+        .add(allow(PUSH).ref("refs/for/refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotUpload(u);
     assertCannotCreateChange("refs/heads/master", u);
   }
 
   @Test
   public void usernamePatternCanUploadToAnyRef() throws Exception {
-    allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
-    ProjectControl u = user(local, "a-registered-user");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/users/${username}/*").group(REGISTERED_USERS))
+        .update();
+    ProjectControl u = user(localKey, "a-registered-user");
     assertCanUpload(u);
   }
 
   @Test
   public void usernamePatternRegExpCanUploadToAnyRef() throws Exception {
-    allow(local, PUSH, REGISTERED_USERS, "^refs/heads/users/${username}/(public|private)/.+");
-    ProjectControl u = user(local, "a-registered-user");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(
+            allow(PUSH)
+                .ref("^refs/heads/users/${username}/(public|private)/.+")
+                .group(REGISTERED_USERS))
+        .update();
+    ProjectControl u = user(localKey, "a-registered-user");
     assertCanUpload(u);
     assertCanUpdate("refs/heads/users/a-registered-user/private/a", u);
   }
 
   @Test
   public void usernamePatternNonRegex() throws Exception {
-    allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/sb/${username}/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "u", DEVS);
-    ProjectControl d = user(local, "d", DEVS);
+    ProjectControl u = user(localKey, "u", DEVS);
+    ProjectControl d = user(localKey, "d", DEVS);
     assertCannotRead("refs/sb/d/heads/foobar", u);
     assertCanRead("refs/sb/d/heads/foobar", d);
   }
 
   @Test
   public void usernamePatternWithRegex() throws Exception {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v", DEVS);
-    ProjectControl d = user(local, "dev", DEVS);
+    ProjectControl u = user(localKey, "d.v", DEVS);
+    ProjectControl d = user(localKey, "dev", DEVS);
     assertCanAccess(u);
     assertCanAccess(d);
     assertCannotRead("refs/sb/dev/heads/foobar", u);
@@ -536,48 +523,80 @@
 
   @Test
   public void usernameEmailPatternWithRegex() throws Exception {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
-    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
+    ProjectControl u = user(localKey, "d.v@ger-rit.org", DEVS);
+    ProjectControl d = user(localKey, "dev@ger-rit.org", DEVS);
     assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
     assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
   }
 
   @Test
   public void sortWithRegex() throws Exception {
-    allow(local, READ, DEVS, "^refs/heads/.*");
-    allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*-QA-.*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
-    ProjectControl d = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCanRead("refs/heads/foo-QA-bar", u);
     assertCanRead("refs/heads/foo-QA-bar", d);
   }
 
   @Test
   public void blockRule_ParentBlocksChild() throws Exception {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    ProjectControl u = user(local, DEVS);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
   public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
   public void blockPartialRangeLocally() throws Exception {
-    block(local, LABEL + "Code-Review", +1, +2, DEVS, "refs/heads/master");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(+1, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(2, range);
@@ -585,10 +604,18 @@
 
   @Test
   public void blockLabelRange_ParentBlocksChild() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -599,11 +626,19 @@
 
   @Test
   public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -614,197 +649,317 @@
 
   @Test
   public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() throws Exception {
-    block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(SUBMIT).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
-    assertThat(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
-        .named("submit is allowed")
+    ProjectControl u = user(localKey);
+    assertWithMessage("submit is allowed")
+        .that(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
         .isTrue();
   }
 
   @Test
   public void unblockNoForce() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockForce() throws Exception {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanForceUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockRead_NotPossible() throws Exception {
-    block(parent, READ, ANONYMOUS_USERS, "refs/*");
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, ANONYMOUS_USERS, "refs/*");
-    allow(local, READ, ADMIN, "refs/*");
-    ProjectControl u = user(local);
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+
+    ProjectControl u = user(localKey);
     assertCannotRead("refs/heads/master", u);
   }
 
   @Test
   public void unblockForceWithAllowNoForce_NotPossible() throws Exception {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotForceUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockMoreSpecificRef_Fails() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockMoreSpecificRefInLocal_Fails() throws Exception {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockMoreSpecificRefWithExclusiveFlag() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockVoteMoreSpecificRefWithExclusiveFlag() throws Exception {
-    String perm = LABEL + "Code-Review";
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .update();
 
-    block(local, perm, -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allowExclusive(local, perm, -2, 2, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(perm);
+    ProjectControl u = user(localKey, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
   }
 
   @Test
   public void unblockFromParentDoesNotAffectChild() throws Exception {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, DEVS, "refs/heads/master");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockFromParentDoesNotAffectChildDifferentGroups() throws Exception {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() throws Exception {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void blockMoreSpecificRefWithinProject() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/secret");
-    allow(local, PUSH, DEVS, "refs/heads/*", true);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/secret").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/*"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/secret", u);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .add(allow(SUBMIT).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(SUBMIT).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockLargerScope_Fails() throws Exception {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
   public void unblockInLocal_Fails() throws Exception {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, fixers, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/*").group(fixers))
+        .update();
 
-    ProjectControl f = user(local, fixers);
+    ProjectControl f = user(localKey, fixers);
     assertCannotUpdate("refs/heads/master", f);
   }
 
   @Test
   public void unblockInParentBlockInLocal() throws Exception {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, PUSH, DEVS, "refs/heads/*");
-    block(local, PUSH, DEVS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl d = user(local, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", d);
   }
 
   @Test
   public void unblockForceEditTopicName() throws Exception {
-    block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can edit topic name")
+    ProjectControl u = user(localKey, DEVS);
+    assertWithMessage("u can edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isTrue();
   }
 
   @Test
   public void unblockInLocalForceEditTopicName_Fails() throws Exception {
-    block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can't edit topic name")
+    ProjectControl u = user(localKey, REGISTERED_USERS);
+    assertWithMessage("u can't edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isFalse();
   }
 
   @Test
   public void unblockRange() throws Exception {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
@@ -812,10 +967,14 @@
 
   @Test
   public void unblockRangeOnMoreSpecificRef_Fails() throws Exception {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -823,10 +982,15 @@
 
   @Test
   public void unblockRangeOnLargerScope_Fails() throws Exception {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -834,9 +998,13 @@
 
   @Test
   public void nonconfiguredCannotVote() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
+    ProjectControl u = user(localKey, REGISTERED_USERS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
@@ -844,10 +1012,18 @@
 
   @Test
   public void unblockInLocalRange_Fails() throws Exception {
-    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -855,9 +1031,13 @@
 
   @Test
   public void unblockRangeForChangeOwner() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
         u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
     assertCanVote(-2, range);
@@ -866,9 +1046,13 @@
 
   @Test
   public void unblockRangeForNotChangeOwner() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -876,9 +1060,13 @@
 
   @Test
   public void blockChangeOwnerVote() throws Exception {
-    block(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -886,10 +1074,14 @@
 
   @Test
   public void unionOfPermissibleVotes() throws Exception {
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
@@ -897,10 +1089,14 @@
 
   @Test
   public void unionOfPermissibleVotesPermissionOrder() throws Exception {
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
@@ -908,11 +1104,19 @@
 
   @Test
   public void unionOfBlockedVotes() throws Exception {
-    allow(parent, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +1, REGISTERED_USERS, "refs/heads/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
     assertCannotVote(1, range);
@@ -920,10 +1124,18 @@
 
   @Test
   public void blockOwner() throws Exception {
-    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
-    allow(local, OWNER, DEVS, "refs/*");
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(OWNER).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(DEVS))
+        .update();
 
-    assertThat(user(local, DEVS).isOwner()).isFalse();
+    assertThat(user(localKey, DEVS).isOwner()).isFalse();
   }
 
   @Test
@@ -935,14 +1147,16 @@
     RefPattern.validate("^refs/heads/review/${username}/.+");
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDoubleCaret() throws Exception {
-    RefPattern.validate("^^refs/*");
+    assertThrows(InvalidNameException.class, () -> RefPattern.validate("^^refs/*"));
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+    assertThrows(
+        InvalidNameException.class,
+        () -> RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*"));
   }
 
   @Test
@@ -950,53 +1164,19 @@
     RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
   }
 
-  private InMemoryRepository add(ProjectConfig pc) {
-    List<CommentLinkInfo> commentLinks = null;
-
-    InMemoryRepository repo;
-    try {
-      repo = repoManager.createRepository(pc.getName());
-      if (pc.getProject() == null) {
-        pc.load(repo);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-    all.put(
-        pc.getName(),
-        new ProjectState(
-            projectCache,
-            allProjectsName,
-            allUsersName,
-            repoManager,
-            commentLinks,
-            capabilityCollectionFactory,
-            transferConfig,
-            metricMaker,
-            pc));
-    return repo;
+  private ProjectState getProjectState(Project.NameKey nameKey) throws Exception {
+    return projectCache.checkedGet(nameKey, true);
   }
 
-  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
-    return user(local, null, memberOf);
+  private ProjectControl user(Project.NameKey localKey, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return user(localKey, null, memberOf);
   }
 
   private ProjectControl user(
-      ProjectConfig local, @Nullable String name, AccountGroup.UUID... memberOf) {
-    return new ProjectControl(
-        Collections.emptySet(),
-        Collections.emptySet(),
-        sectionSorter,
-        changeControlFactory,
-        permissionBackend,
-        refFilterFactory,
-        new MockUser(name, memberOf),
-        newProjectState(local));
-  }
-
-  private ProjectState newProjectState(ProjectConfig local) {
-    add(local);
-    return all.get(local.getProject().getNameKey());
+      Project.NameKey localKey, @Nullable String name, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return projectControlFactory.create(new MockUser(name, memberOf), getProjectState(localKey));
   }
 
   private static class MockUser extends CurrentUser {
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index cf6d50f..61a2d2f 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -14,27 +14,31 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
@@ -44,12 +48,13 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
 /** Unit tests for {@link CommitsCollection}. */
-public class CommitsCollectionTest extends GerritBaseTests {
+public class CommitsCollectionTest {
   @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
 
   @Inject private AccountManager accountManager;
@@ -58,10 +63,10 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected AllProjectsName allProjects;
   @Inject private CommitsCollection commits;
-  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private ProjectOperations projectOperations;
 
   private TestRepository<InMemoryRepository> repo;
-  private ProjectConfig project;
+  private Project.NameKey project;
 
   @Before
   public void setUp() throws Exception {
@@ -69,17 +74,22 @@
 
     Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     testEnvironment.setApiUser(user);
+    project = projectOperations.newProject().create();
+    repo = new TestRepository<>(repoManager.openRepository(project));
+  }
 
-    Project.NameKey name = new Project.NameKey("project");
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = projectConfigFactory.create(name);
-    project.load(inMemoryRepo);
-    repo = new TestRepository<>(inMemoryRepo);
+  @After
+  public void tearDown() {
+    repo.getRepository().close();
   }
 
   @Test
   public void canReadCommitWhenAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     ObjectId id = repo.branch("master").commit().create();
     ProjectState state = readProjectState();
     RevWalk rw = repo.getRevWalk();
@@ -90,8 +100,12 @@
 
   @Test
   public void canReadCommitIfTwoRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -106,8 +120,12 @@
 
   @Test
   public void canReadCommitIfRefVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -122,8 +140,12 @@
 
   @Test
   public void canReadCommitIfReachableFromVisibleRef() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     repo.branch("branch1").commit().parent(parent1).create();
@@ -140,7 +162,11 @@
 
   @Test
   public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -159,7 +185,11 @@
 
   @Test
   public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -177,41 +207,19 @@
   }
 
   private ProjectState readProjectState() throws Exception {
-    return projectCache.get(project.getName());
-  }
-
-  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.allow(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.deny(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
+    return projectCache.get(project);
   }
 
   private void setUpPermissions() throws Exception {
-    ImmutableList<AccountGroup.UUID> admins = getAdmins();
-
     // Remove read permissions for all users besides admin, because by default
     // Anonymous user group has ALLOW READ permission in refs/*.
     // This method is idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    for (AccountGroup.UUID admin : admins) {
-      allow(pc, Permission.READ, admin, "refs/*");
-    }
+    TestProjectUpdate.Builder u = projectOperations.allProjectsForUpdate();
+    projectCache.checkedGet(allProjects).getConfig().getAccessSectionNames().stream()
+        .filter(sec -> sec.startsWith(R_REFS))
+        .forEach(sec -> u.remove(permissionKey(Permission.READ).ref(sec)));
+    getAdmins().forEach(admin -> u.add(allow(Permission.READ).ref("refs/*").group(admin)));
+    u.update();
   }
 
   private ImmutableList<AccountGroup.UUID> getAdmins() {
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 08aca9f..518f85d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -14,22 +14,19 @@
 
 package com.google.gerrit.server.project;
 
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
@@ -37,8 +34,8 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupListTest extends GerritBaseTests {
-  private static final Project.NameKey PROJECT = new Project.NameKey("project");
+public class GroupListTest {
+  private static final Project.NameKey PROJECT = Project.nameKey("project");
   private static final String TEXT =
       "# UUID                                  \tGroup Name\n"
           + "#\n"
@@ -49,14 +46,13 @@
 
   @Before
   public void setup() throws IOException {
-    ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
-    replay(sink);
+    ValidationError.Sink sink = mock(ValidationError.Sink.class);
     groupList = GroupList.parse(PROJECT, TEXT, sink);
   }
 
   @Test
   public void byUUID() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
 
     GroupReference groupReference = groupList.byUUID(uuid);
 
@@ -66,7 +62,7 @@
 
   @Test
   public void put() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("abc");
+    AccountGroup.UUID uuid = AccountGroup.uuid("abc");
     GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
 
     groupList.put(uuid, groupReference);
@@ -81,7 +77,7 @@
     Collection<GroupReference> result = groupList.references();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID uuid = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID uuid = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     GroupReference expected = new GroupReference(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
@@ -92,27 +88,24 @@
     Set<AccountGroup.UUID> result = groupList.uuids();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID expected = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID expected = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     assertTrue(result.contains(expected));
   }
 
   @Test
   public void validationError() throws Exception {
-    ValidationError.Sink sink = createMock(ValidationError.Sink.class);
-    sink.error(anyObject(ValidationError.class));
-    expectLastCall().times(2);
-    replay(sink);
+    ValidationError.Sink sink = mock(ValidationError.Sink.class);
     groupList = GroupList.parse(PROJECT, TEXT.replace("\t", "    "), sink);
-    verify(sink);
+    verify(sink, times(2)).error(any(ValidationError.class));
   }
 
   @Test
   public void retainAll() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
     groupList.retainUUIDs(Collections.singleton(uuid));
 
     assertNotNull(groupList.byUUID(uuid));
-    assertNull(groupList.byUUID(new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
+    assertNull(groupList.byUUID(AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 3436153..0dd6436 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.gerrit.reviewdb.client.BooleanProjectConfig.REQUIRE_CHANGE_ID;
+import static com.google.gerrit.entities.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -26,18 +26,17 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.project.testing.TestLabels;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -62,9 +61,12 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class ProjectConfigTest extends GerritBaseTests {
+public class ProjectConfigTest {
   private static final String LABEL_SCORES_CONFIG =
-      "  copyMinScore = "
+      "  copyAnyScore = "
+          + !LabelType.DEF_COPY_ANY_SCORE
+          + "\n"
+          + "  copyMinScore = "
           + !LabelType.DEF_COPY_MIN_SCORE
           + "\n"
           + "  copyMaxScore = "
@@ -88,8 +90,8 @@
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private final GroupReference developers =
-      new GroupReference(new AccountGroup.UUID("X"), "Developers");
-  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
+      new GroupReference(AccountGroup.uuid("X"), "Developers");
+  private final GroupReference staff = new GroupReference(AccountGroup.uuid("Y"), "Staff");
 
   private SitePaths sitePaths;
   private ProjectConfig.Factory factory;
@@ -260,6 +262,7 @@
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     LabelType type = labels.entrySet().iterator().next().getValue();
+    assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
     assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
     assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
     assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
@@ -319,17 +322,17 @@
                 + "\tsubmit = group Staff\n"
                 + "  upload = group Developers\n"
                 + "  read = group Developers\n"
-                + "[accounts]\n"
-                + "  sameGroupVisibility = group Staff\n"
-                + "[contributor-agreement \"Individual\"]\n"
-                + "  description = A new description\n"
-                + "  accepted = group Staff\n"
-                + "  agreementUrl = http://www.example.com/agree\n"
-                + "\texcludeProjects = ^/theirproject\n"
                 + "[label \"CustomLabel\"]\n"
                 + LABEL_SCORES_CONFIG
                 + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
-                + "\tdefaultValue = 0\n"); //  label gets this value when it is created
+                + "\tdefaultValue = 0\n" //  label gets this value when it is created
+                + "[accounts]\n"
+                + "\tsameGroupVisibility = group Staff\n"
+                + "[contributor-agreement \"Individual\"]\n"
+                + "\tdescription = A new description\n"
+                + "\tagreementUrl = http://www.example.com/agree\n"
+                + "\taccepted = group Staff\n"
+                + "\texcludeProjects = ^/theirproject\n");
   }
 
   @Test
@@ -341,11 +344,11 @@
     cfg.getLabelSections()
         .put(
             "My-Label",
-            Util.category(
+            TestLabels.label(
                 "My-Label",
-                Util.value(-1, "Negative"),
-                Util.value(0, "No score"),
-                Util.value(1, "Positive")));
+                TestLabels.value(-1, "Negative"),
+                TestLabels.value(0, "No score"),
+                TestLabels.value(1, "Positive")));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -422,7 +425,7 @@
 
   @Test
   public void readUnexistingPluginConfig() throws Exception {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).isEmpty();
@@ -625,7 +628,7 @@
 
   @Test
   public void readOtherProjectIgnoresAllProjectsBaseConfig() throws Exception {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db);
     assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
         .isEqualTo(InheritableBoolean.INHERIT);
@@ -641,6 +644,118 @@
         .isEqualTo(InheritableBoolean.INHERIT);
   }
 
+  @Test
+  public void accountsSectionIsUnsetIfNoSameGroupVisibilityIsSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n"
+                    + "[accounts]\n"
+                    + "  sameGroupVisibility = group Staff\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getAccountsSection().setSameGroupVisibility(ImmutableList.of());
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+  }
+
+  @Test
+  public void contributorSectionIsUnsetIfNoContributorAgreementIsSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n"
+                    + "[contributor-agreement \"Individual\"]\n"
+                    + "  accepted = group Developers\n"
+                    + "  accepted = group Staff\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    ContributorAgreement section = cfg.getContributorAgreement("Individual");
+    section.setAccepted(ImmutableList.of());
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+  }
+
+  @Test
+  public void notifySectionIsUnsetIfNoNotificationsAreSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n"
+                    + "[notify \"name\"]\n"
+                    + "  email = example@example.com\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getNotifyConfigs().clear();
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+  }
+
+  @Test
+  public void commentLinkSectionIsUnsetIfNoCommentLinksAreSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n"
+                    + "[notify \"name\"]\n"
+                    + "  email = example@example.com\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getCommentLinkSections().clear();
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[notify \"name\"]\n\temail = example@example.com\n");
+  }
+
+  @Test
+  public void pluginSectionIsUnsetIfAllPluginConfigsAreEmpty() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n"
+                    + "[plugin \"somePlugin\"]\n"
+                    + "  key = value\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    pluginCfg.unset("key");
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+  }
+
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
@@ -648,7 +763,7 @@
   }
 
   private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db, rev);
     return cfg;
   }
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index a2b2866..e7f0812 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.server.query.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
@@ -47,8 +51,6 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -79,6 +81,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -93,10 +96,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryAccountsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -253,7 +259,7 @@
     addEmails(user1, secondaryEmail);
 
     AccountInfo user2 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user2._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
     if (getSchemaVersion() < 5) {
       assertMissingField(AccountField.PREFERRED_EMAIL);
@@ -340,7 +346,7 @@
     AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
 
     AccountInfo user3 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user3._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
 
     assertQuery("notexisting");
     assertQuery("Not Existing");
@@ -575,13 +581,13 @@
     String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
     addEmails(otherUser, secondaryEmails);
 
-    requestContext.setContext(newRequestContext(new Account.Id(user._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user._accountId)));
 
     List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
-
-    exception.expect(AuthException.class);
-    newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get();
+    assertThrows(
+        AuthException.class,
+        () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
   }
 
   @Test
@@ -600,7 +606,7 @@
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
 
     // update account without reindex so that account index is stale
-    Account.Id accountId = new Account.Id(user1._accountId);
+    Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
     try (Repository repo = repoManager.openRepository(allUsers)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
@@ -625,19 +631,22 @@
   public void rawDocument() throws Exception {
     AccountInfo userInfo = gApi.accounts().id(admin.getAccountId().get()).get();
 
+    Schema<AccountState> schema = indexes.getSearchIndex().getSchema();
     Optional<FieldBundle> rawFields =
         indexes
             .getSearchIndex()
             .getRaw(
-                new Account.Id(userInfo._accountId),
+                Account.id(userInfo._accountId),
                 QueryOptions.create(
-                    IndexConfig.createDefault(),
-                    0,
-                    1,
-                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+                    IndexConfig.createDefault(), 0, 1, schema.getStoredFields().keySet()));
 
     assertThat(rawFields).isPresent();
-    assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
+    if (schema.useLegacyNumericFields()) {
+      assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
+    } else {
+      assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
+          .isEqualTo(userInfo._accountId);
+    }
 
     // The field EXTERNAL_ID_STATE is only supported from schema version 6.
     if (getSchemaVersion() < 6) {
@@ -695,7 +704,7 @@
     in.name = name;
     in.createEmptyCommit = true;
     gApi.projects().create(in);
-    return new Project.NameKey(name);
+    return Project.nameKey(name);
   }
 
   protected void blockRead(Project.NameKey project, GroupInfo group) throws RestApiException {
@@ -750,7 +759,7 @@
       return null;
     }
 
-    String suffix = getSanitizedMethodName();
+    String suffix = testName.getSanitizedMethodName();
     if (name.contains("@")) {
       return name + "." + suffix;
     }
@@ -777,7 +786,7 @@
   }
 
   private void addEmails(AccountInfo account, String... emails) throws Exception {
-    Account.Id id = new Account.Id(account._accountId);
+    Account.Id id = Account.id(account._accountId);
     for (String email : emails) {
       accountManager.link(id, AuthRequest.forEmail(email));
     }
@@ -802,15 +811,15 @@
       throws Exception {
     List<AccountInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, result, accounts))
+    assertWithMessage(format(query, result, accounts))
+        .that(ids)
         .containsExactlyElementsIn(ids(accounts))
         .inOrder();
     return result;
   }
 
   protected void assertAccounts(List<AccountState> accounts, AccountInfo... expectedAccounts) {
-    assertThat(accounts.stream().map(a -> a.getAccount().getId().get()).collect(toList()))
+    assertThat(accounts.stream().map(a -> a.account().id().get()).collect(toList()))
         .containsExactlyElementsIn(
             Arrays.asList(expectedAccounts).stream().map(a -> a._accountId).collect(toList()));
   }
@@ -860,8 +869,8 @@
   }
 
   protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index ba0f779..5c910a0 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -9,19 +9,20 @@
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     runtime_deps = [
+        "//java/com/google/gerrit/lucene",
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
@@ -39,7 +40,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index ff45c08..558658b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -16,14 +16,15 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+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.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
-import static com.google.gerrit.server.project.testing.Util.verified;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -40,11 +41,21 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -74,14 +85,6 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -93,6 +96,7 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.VersionedAccountQueries;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
@@ -107,7 +111,6 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -159,7 +162,7 @@
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected ChangeQueryBuilder queryBuilder;
+  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
@@ -180,7 +183,9 @@
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
-  @Inject protected ProjectConfig.Factory projectConfigFactory;
+
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private ProjectOperations projectOperations;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
@@ -416,13 +421,6 @@
 
   @Test
   public void byPrivate() throws Exception {
-    if (getSchemaVersion() < 40) {
-      assertMissingField(ChangeField.PRIVATE);
-      assertFailingQuery(
-          "is:private", "'is:private' operator is not supported by change index version");
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
     Account.Id user2 =
@@ -447,12 +445,6 @@
 
   @Test
   public void byWip() throws Exception {
-    if (getSchemaVersion() < 42) {
-      assertMissingField(ChangeField.WIP);
-      assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
 
@@ -469,24 +461,7 @@
   }
 
   @Test
-  public void excludeWipChangeFromReviewersDashboardsBeforeSchema42() throws Exception {
-    assume().that(getSchemaVersion()).isLessThan(42);
-
-    assertMissingField(ChangeField.WIP);
-    assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
-
-    Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
-    assertQuery("reviewer:" + user1, change1);
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
-    assertQuery("reviewer:" + user1, change1);
-  }
-
-  @Test
   public void excludeWipChangeFromReviewersDashboards() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(42);
-
     Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
@@ -504,17 +479,7 @@
   }
 
   @Test
-  public void byStartedBeforeSchema44() throws Exception {
-    assume().that(getSchemaVersion()).isLessThan(44);
-    assertMissingField(ChangeField.STARTED);
-    assertFailingQuery(
-        "is:started", "'is:started' operator is not supported by change index version");
-  }
-
-  @Test
   public void byStarted() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(44);
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWorkInProgress(repo));
 
@@ -550,9 +515,7 @@
 
   @Test
   public void restorePendingReviewers() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(44);
-
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -569,7 +532,7 @@
             .reviewer(user2.toString(), ReviewerState.CC, false)
             .reviewer(email1)
             .reviewer(email2, ReviewerState.CC, false);
-    gApi.changes().id(change1.getId().get()).revision("current").review(in);
+    gApi.changes().id(change1.getId().get()).current().review(in);
 
     List<ChangeInfo> changeInfos =
         assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
@@ -579,8 +542,7 @@
         changeInfos.get(0).pendingReviewers;
     assertThat(pendingReviewers).isNotNull();
 
-    assertReviewers(
-        pendingReviewers.get(ReviewerState.REVIEWER), userId.toString(), user1.toString(), email1);
+    assertReviewers(pendingReviewers.get(ReviewerState.REVIEWER), user1.toString(), email1);
     assertReviewers(pendingReviewers.get(ReviewerState.CC), user2.toString(), email2);
     assertReviewers(pendingReviewers.get(ReviewerState.REMOVED));
 
@@ -710,10 +672,10 @@
     assertQuery(searchOperator + "\"John Smith\"");
 
     // By invalid query.
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid value");
     // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
-    assertQuery(searchOperator + "@.- /_");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery(searchOperator + "@.- /_"));
+    assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
   private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
@@ -1054,20 +1016,24 @@
   public void byLabelMulti() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
 
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    String heads = RefNames.REFS_HEADS + "*";
-    allow(cfg, Permission.forLabel(verified().getName()), -1, 1, REGISTERED_USERS, heads);
-
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(md);
+      cfg.getLabelSections().put(verified.getName(), verified);
       cfg.commit(md);
     }
-    projectCache.evict(cfg.getProject());
+    projectCache.evict(project);
+
+    String heads = RefNames.REFS_HEADS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
     ChangeInserter ins = newChange(repo);
@@ -1204,9 +1170,9 @@
       }
       String q = "status:new limit:" + i;
       List<ChangeInfo> results = newQuery(q).get();
-      assertThat(results).named(q).hasSize(expectedSize);
-      assertThat(results.get(results.size() - 1)._moreChanges)
-          .named(q)
+      assertWithMessage(q).that(results).hasSize(expectedSize);
+      assertWithMessage(q)
+          .that(results.get(results.size() - 1)._moreChanges)
           .isEqualTo(expectedMoreChanges);
       assertThat(results.get(0)._number).isEqualTo(last.getId().get());
     }
@@ -1378,15 +1344,6 @@
 
   @Test
   public void byExtension() throws Exception {
-    if (getSchemaVersion() < 52) {
-      assertMissingField(ChangeField.EXTENSION);
-      String unsupportedOperatorMsg =
-          "'extension' operator is not supported by change index version";
-      assertFailingQuery("extension:txt", unsupportedOperatorMsg);
-      assertFailingQuery("ext:txt", unsupportedOperatorMsg);
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
     Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
@@ -1410,15 +1367,6 @@
 
   @Test
   public void byOnlyExtensions() throws Exception {
-    if (getSchemaVersion() < 53) {
-      assertMissingField(ChangeField.ONLY_EXTENSIONS);
-      String unsupportedOperatorMessage =
-          "'onlyextensions' operator is not supported by change index version";
-      assertFailingQuery("onlyextensions:txt,jpg", unsupportedOperatorMessage);
-      assertFailingQuery("onlyexts:txt,jpg", unsupportedOperatorMessage);
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
     Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
@@ -1466,14 +1414,6 @@
 
   @Test
   public void byFooter() throws Exception {
-    if (getSchemaVersion() < 54) {
-      assertMissingField(ChangeField.FOOTER);
-      assertFailingQuery(
-          "footer:Change-Id=I3d2b978ed455f835d1dad2daa920be0b0ec2ae36",
-          "'footer' operator is not supported by change index version");
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
@@ -1523,15 +1463,6 @@
 
   @Test
   public void byDirectory() throws Exception {
-    if (getSchemaVersion() < 55) {
-      assertMissingField(ChangeField.DIRECTORY);
-      String unsupportedOperatorMessage =
-          "'directory' operator is not supported by change index version";
-      assertFailingQuery("directory:src/java", unsupportedOperatorMessage);
-      assertFailingQuery("dir:src/java", unsupportedOperatorMessage);
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
     Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
@@ -1598,8 +1529,6 @@
 
   @Test
   public void byDirectoryRegex() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(55);
-
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change2 =
@@ -1874,11 +1803,11 @@
 
     // change is visible to group ONLY when access is granted
     grant(
-        new Project.NameKey("repo"),
+        Project.nameKey("repo"),
         "refs/*",
         Permission.READ,
         false,
-        new AccountGroup.UUID(gApi.groups().id(g1).get().id));
+        AccountGroup.uuid(gApi.groups().id(g1).get().id));
     assertQuery(q + " visibleto:" + g1, change1);
 
     // Both changes are visible to InternalUser
@@ -1929,7 +1858,7 @@
       ProjectConfig config = projectConfigFactory.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
-      PermissionRule rule = Util.newRule(config, groupUUID);
+      PermissionRule rule = new PermissionRule(new GroupReference(groupUUID, groupUUID.get()));
       rule.setForce(force);
       p.add(rule);
       config.commit(md);
@@ -2010,7 +1939,7 @@
 
   @Test
   public void byDraftByExcludesZombieDrafts() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     Change.Id id = change.getId();
@@ -2179,8 +2108,15 @@
     assertQuery("conflicts:" + change2.getId().get(), change1);
     assertQuery("is:mergeable", change2, change1);
 
-    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).current().submit();
+
+    // If a change gets submitted, the remaining open changes get reindexed asynchronously to update
+    // their mergeability information. If the further assertions in this test are done before the
+    // asynchronous reindex completed they fail because the mergeability information in the index
+    // was not updated yet. To avoid this flakiness reindexAfterRefUpdate is switched off for the
+    // tests and we index change2 synchronously here.
+    gApi.changes().id(change2.getChangeId()).index();
 
     assertQuery("status:open conflicts:" + change2.getId().get());
     assertQuery("status:open is:mergeable");
@@ -2249,7 +2185,7 @@
 
     assertQuery("is:reviewer");
     assertQuery("reviewer:self");
-    gApi.changes().id(change3.getChangeId()).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(change3.getChangeId()).current().review(ReviewInput.recommend());
     assertQuery("is:reviewer", change3);
     assertQuery("reviewer:self", change3);
 
@@ -2330,7 +2266,7 @@
 
   @Test
   public void reviewerAndCcByEmail() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2353,30 +2289,17 @@
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
 
-    if (getSchemaVersion() >= 41) {
-      assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
-      assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+    assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+    assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
 
-      // Omitting the name:
-      assertQuery("reviewer:\"" + userByEmail + "\"", change1);
-      assertQuery("cc:\"" + userByEmail + "\"", change2);
-    } else {
-      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
-
-      assertFailingQuery(
-          "reviewer:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
-      assertFailingQuery(
-          "cc:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
-
-      // Omitting the name:
-      assertFailingQuery("reviewer:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
-      assertFailingQuery("cc:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
-    }
+    // Omitting the name:
+    assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+    assertQuery("cc:\"" + userByEmail + "\"", change2);
   }
 
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2398,17 +2321,8 @@
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
 
-    if (getSchemaVersion() >= 41) {
-      assertQuery("reviewer:\"someone@example.com\"");
-      assertQuery("cc:\"someone@example.com\"");
-    } else {
-      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
-
-      String someoneEmail = "someone@example.com";
-      assertFailingQuery(
-          "reviewer:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
-      assertFailingQuery("cc:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
-    }
+    assertQuery("reviewer:\"someone@example.com\"");
+    assertQuery("cc:\"someone@example.com\"");
   }
 
   @Test
@@ -2511,7 +2425,7 @@
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ObjectId missing =
-        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+        repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
             .message("No change for this commit")
             .insertChangeId()
@@ -2526,7 +2440,7 @@
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
-    Branch.NameKey dest = null;
+    BranchNameKey dest = null;
     for (int i = 0; i < n; i++) {
       ChangeInserter ins = newChange(repo);
       insert(repo, ins);
@@ -2542,15 +2456,15 @@
           queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
       Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
       String name = "limit " + i;
-      assertThat(ids).named(name).hasSize(n);
-      assertThat(ids).named(name).containsExactlyElementsIn(expectedIds);
+      assertWithMessage(name).that(ids).hasSize(n);
+      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
     }
   }
 
   @Test
   public void reindexIfStale() throws Exception {
     Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     String changeId = change.getKey().get();
@@ -2563,8 +2477,7 @@
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
 
     // Delete edit ref behind index's back.
-    RefUpdate ru =
-        repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.getId()));
+    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
 
@@ -2648,13 +2561,6 @@
 
   @Test
   public void revertOf() throws Exception {
-    if (getSchemaVersion() < 45) {
-      assertMissingField(ChangeField.REVERT_OF);
-      assertFailingQuery(
-          "revertof:1", "'revertof' operator is not supported by change index version");
-      return;
-    }
-
     TestRepository<Repo> repo = createProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
     Change initial = insert(repo, newChange(repo));
@@ -2667,8 +2573,7 @@
     gApi.changes().id(changeToRevert.id).current().submit();
 
     ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
-    assertQueryByIds(
-        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
+    assertQueryByIds("revertof:" + changeToRevert._number, Change.id(changeThatReverts._number));
   }
 
   /** Change builder for helping in tests for dashboard sections. */
@@ -3123,27 +3028,27 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
 
-    String queries =
+    String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
             + "query3\tproject:repo branch:stable\n"
             + "query4\tproject:repo branch:other";
 
     try (TestRepository<Repo> allUsers =
-        new TestRepository<>(repoManager.openRepository(allUsersName))) {
-      String refsUsers = RefNames.refsUsers(userId);
-      allUsers.branch(refsUsers).commit().add("queries", queries).create();
-
-      Ref userRef = allUsers.getRepository().exactRef(refsUsers);
-      assertThat(userRef).isNotNull();
+            new TestRepository<>(repoManager.openRepository(allUsersName));
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+      VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
+      queries.load(md);
+      queries.setQueryList(queryListText);
+      queries.commit(md);
     }
 
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
-    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
@@ -3221,6 +3126,7 @@
 
     assertQuery(ChangeIndexPredicate.none());
 
+    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -3293,7 +3199,7 @@
       branch = "refs/heads/" + branch;
     }
 
-    Change.Id id = new Change.Id(seq.nextChangeId());
+    Change.Id id = Change.id(seq.nextChangeId());
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
@@ -3321,7 +3227,7 @@
       Timestamp createdOn)
       throws Exception {
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
     try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
@@ -3340,7 +3246,7 @@
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(changeNotesFactory.createChecked(c), new PatchSet.Id(c.getId(), n), commit)
+            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
             .setFireRevisionCreated(false)
             .setValidate(false);
     try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
@@ -3380,7 +3286,7 @@
 
   protected TestRepository<Repo> createProject(String name) throws Exception {
     gApi.projects().create(name).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
@@ -3388,7 +3294,7 @@
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -3412,8 +3318,8 @@
       throws Exception {
     List<ChangeInfo> result = query.get();
     Iterable<Change.Id> ids = ids(result);
-    assertThat(ids)
-        .named(format(query.getQuery(), ids, changes))
+    assertWithMessage(format(query.getQuery(), ids, changes))
+        .that(ids)
         .containsExactlyElementsIn(Arrays.asList(changes))
         .inOrder();
     return result;
@@ -3425,8 +3331,8 @@
             .map(ChangeData::getId)
             .collect(toImmutableList());
     Change.Id[] expectedIds = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
-    assertThat(actualIds)
-        .named(format(predicate.toString(), actualIds, expectedIds))
+    assertWithMessage(format(predicate.toString(), actualIds, expectedIds))
+        .that(actualIds)
         .containsExactlyElementsIn(expectedIds)
         .inOrder();
   }
@@ -3457,7 +3363,7 @@
           .append(c.changeId)
           .append("), ")
           .append("dest=")
-          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
+          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
           .append(", ")
           .append("status=")
           .append(c.status)
@@ -3478,7 +3384,7 @@
   }
 
   protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
-    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
+    return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
   }
 
   protected static long lastUpdatedMs(Change c) {
@@ -3515,8 +3421,8 @@
   }
 
   protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 8347484..d0162d3 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -1,7 +1,10 @@
 load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-ABSTRACT_QUERY_TEST = ["AbstractQueryChangesTest.java"]
+ABSTRACT_QUERY_TEST = [
+    "AbstractQueryChangesTest.java",
+    "LuceneQueryChangesTest.java",
+]
 
 java_library(
     name = "abstract_query_tests",
@@ -9,48 +12,53 @@
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     runtime_deps = [
+        "//java/com/google/gerrit/lucene",
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
     ],
 )
 
-LUCENE_QUERY_TEST = ["LuceneQueryChangesTest.java"]
+LUCENE_QUERY_TEST = [
+    "LuceneQueryChangesLatestIndexVersionTest.java",
+    "LuceneQueryChangesPreviousIndexVersionTest.java",
+]
 
-junit_tests(
-    name = "lucene_query_test",
+[junit_tests(
+    name = f[:f.index(".")],
     size = "large",
-    srcs = LUCENE_QUERY_TEST,
+    srcs = [f],
     visibility = ["//visibility:public"],
     deps = [
         ":abstract_query_tests",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
     ],
-)
+) for f in LUCENE_QUERY_TEST]
 
 junit_tests(
     name = "small_tests",
@@ -61,15 +69,15 @@
     ),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/proto/testing",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib:jgit",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index aba0018..e42230f 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -17,26 +17,36 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ChangeDataTest extends GerritBaseTests {
+public class ChangeDataTest {
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
-    Project.NameKey project = new Project.NameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    cd.setChange(TestChanges.newChange(project, Account.id(1000)));
     PatchSet curr1 = cd.currentPatchSet();
-    int currId = curr1.getId().get();
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 2));
+    int currId = curr1.id().get();
+    PatchSet ps1 = newPatchSet(cd.getId(), currId + 1);
+    PatchSet ps2 = newPatchSet(cd.getId(), currId + 2);
     cd.setPatchSets(ImmutableList.of(ps1, ps2));
     PatchSet curr2 = cd.currentPatchSet();
     assertThat(curr2).isNotSameInstanceAs(curr1);
   }
+
+  private static PatchSet newPatchSet(Change.Id changeId, int num) {
+    return PatchSet.builder()
+        .id(PatchSet.id(changeId, num))
+        .commitId(ObjectId.zeroId())
+        .uploader(Account.id(1234))
+        .createdOn(TimeUtil.nowTs())
+        .build();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index e550f8e..00c1a80 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -25,11 +25,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ConflictKeyTest extends GerritBaseTests {
+public class ConflictKeyTest {
   @Test
   public void ffOnlyPreservesInputOrder() {
     ObjectId id1 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java
new file mode 100644
index 0000000..52a9170
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryChangesLatestIndexVersionTest extends LuceneQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForLucene();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java
new file mode 100644
index 0000000..62483fa
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryChangesPreviousIndexVersionTest extends LuceneQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    return Iterables.getOnlyElement(
+        IndexVersions.asConfigMap(
+                ChangeSchemaDefinitions.INSTANCE,
+                IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE),
+                "againstIndexVersion",
+                IndexConfig.createForLucene())
+            .values());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 2ea198f..6a83fb9 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -14,37 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-public class LuceneQueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForLucene();
-  }
-
-  @ConfigSuite.Configs
-  public static Map<String, Config> againstPreviousIndexVersion() {
-    // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE);
-    return IndexVersions.asConfigMap(
-        ChangeSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
-  }
-
+public abstract class LuceneQueryChangesTest extends AbstractQueryChangesTest {
   @Override
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
@@ -76,8 +60,10 @@
     Change change1 = insert(repo, newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot create full-text query with value: \\");
-    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+    assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index 135e9c26..72fdd14 100644
--- a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -17,13 +17,13 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import java.util.Arrays;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class RegexPathPredicateTest extends GerritBaseTests {
+public class RegexPathPredicateTest {
   @Test
   public void prefixOnlyOptimization() {
     RegexPathPredicate p = predicate("^a/b/.*");
@@ -83,7 +83,8 @@
 
   private static ChangeData change(String... files) {
     Arrays.sort(files);
-    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"), new Change.Id(1), 1);
+    ChangeData cd =
+        ChangeData.createForTest(Project.nameKey("project"), Change.id(1), 1, ObjectId.zeroId());
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 4a3c755..d80eac0 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -15,11 +15,15 @@
 package com.google.gerrit.server.query.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -32,8 +36,6 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,6 +59,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -68,10 +71,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryGroupsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -184,7 +190,7 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
     GroupInfo group1 = createGroup("group-" + namePart);
@@ -204,9 +210,9 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
   }
 
   @Test
@@ -340,7 +346,7 @@
 
     // update group in the database so that group index is stale
     String newDescription = "barY";
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(group1.id);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription(newDescription).build();
     groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupUpdate);
@@ -356,7 +362,7 @@
   @Test
   public void rawDocument() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group1.id);
 
     Optional<FieldBundle> rawFields =
         indexes
@@ -376,7 +382,7 @@
   @Test
   public void byDeletedGroup() throws Exception {
     GroupInfo group = createGroup(name("group"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
@@ -459,8 +465,8 @@
       throws Exception {
     List<GroupInfo> result = query.get();
     Iterable<String> uuids = uuids(result);
-    assertThat(uuids)
-        .named(format(query, result, groups))
+    assertWithMessage(format(query, result, groups))
+        .that(uuids)
         .containsExactlyElementsIn(uuids(groups))
         .inOrder();
     return result;
@@ -535,7 +541,7 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 
   protected int getSchemaVersion() {
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 1271f4e..e14350f 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -8,17 +8,18 @@
     testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
+    runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
@@ -36,7 +37,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 08ef2b0..dfd7928 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -15,12 +15,16 @@
 package com.google.gerrit.server.query.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
@@ -37,8 +41,6 @@
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,6 +59,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -68,10 +71,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -185,7 +191,7 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
     ProjectInfo project1 = createProject(name("project1-" + namePart));
@@ -207,9 +213,9 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
   }
 
   @Test
@@ -224,16 +230,18 @@
 
   @Test
   public void byState_emptyQuery() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("state operator requires a value");
-    assertQuery("state:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:\"\""));
+    assertThat(thrown).hasMessageThat().contains("state operator requires a value");
   }
 
   @Test
   public void byState_badQuery() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("state operator must be either 'active' or 'read-only'");
-    assertQuery("state:bla");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:bla"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("state operator must be either 'active' or 'read-only'");
   }
 
   @Test
@@ -375,8 +383,8 @@
       throws Exception {
     List<ProjectInfo> result = query.get();
     Iterable<String> names = names(result);
-    assertThat(names)
-        .named(format(query, result, projects))
+    assertWithMessage(format(query, result, projects))
+        .that(names)
         .containsExactlyElementsIn(names(projects))
         .inOrder();
     return result;
@@ -443,6 +451,6 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 5afc7da..984d824 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -8,18 +8,19 @@
     testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
+    runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
@@ -36,7 +37,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 1e335db..250b0ce 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -8,14 +8,15 @@
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/mockito",
         "//lib/prolog:runtime",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 180c16b..9d7afbc 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.rules;
 
-import static org.easymock.EasyMock.expect;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -29,7 +32,6 @@
 import java.io.PushbackReader;
 import java.io.StringReader;
 import java.util.Arrays;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
@@ -56,10 +58,10 @@
 
   @Override
   protected void setUpEnvironment(PrologEnvironment env) throws Exception {
-    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
-    ChangeData cd = EasyMock.createMock(ChangeData.class);
-    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(cd);
+    LabelTypes labelTypes =
+        new LabelTypes(Arrays.asList(TestLabels.codeReview(), TestLabels.verified()));
+    ChangeData cd = mock(ChangeData.class);
+    when(cd.getLabelTypes()).thenReturn(labelTypes);
     env.set(StoredValues.CHANGE_DATA, cd);
   }
 
@@ -82,11 +84,14 @@
       throw new CompileException("Cannot consult " + nameTerm);
     }
 
-    exception.expect(ReductionLimitException.class);
-    exception.expectMessage("exceeded reduction limit of 1300");
-    env.once(
-        Prolog.BUILTIN,
-        "call",
-        new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy")));
+    ReductionLimitException thrown =
+        assertThrows(
+            ReductionLimitException.class,
+            () ->
+                env.once(
+                    Prolog.BUILTIN,
+                    "call",
+                    new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy"))));
+    assertThat(thrown).hasMessageThat().contains("exceeded reduction limit of 1300");
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index 14124fa..d8af0e5 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -19,12 +19,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -32,9 +31,9 @@
 import java.util.List;
 import org.junit.Test;
 
-public class IgnoreSelfApprovalRuleTest extends GerritBaseTests {
-  private static final Change.Id CHANGE_ID = new Change.Id(100);
-  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+public class IgnoreSelfApprovalRuleTest {
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
   private static final LabelType VERIFIED = makeLabel("Verified");
   private static final Account.Id USER1 = makeAccount(100001);
 
@@ -82,16 +81,14 @@
   }
 
   private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
-    PatchSetApproval.Key key = makeKey(PS_ID, accountId, labelId);
-    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
-  }
-
-  private static PatchSetApproval.Key makeKey(
-      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
-    return new PatchSetApproval.Key(psId, accountId, labelId);
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, accountId, labelId))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
   }
 
   private static Account.Id makeAccount(int account) {
-    return new Account.Id(account);
+    return Account.id(account);
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index 6eb0747..8622b32 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class PrologRuleEvaluatorTest extends GerritBaseTests {
+public class PrologRuleEvaluatorTest {
 
   @Test
   public void validLabelNamesAreKept() {
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
index f4d8eac..c2b6dbb 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
@@ -19,7 +19,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Module;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -45,7 +44,7 @@
 
 /** Base class for any tests written in Prolog. */
 @Ignore
-public abstract class PrologTestCase extends GerritBaseTests {
+public abstract class PrologTestCase {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
   private String pkg;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index e5890c9..d5ddeff 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -21,20 +21,20 @@
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.Config;
@@ -43,7 +43,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class AllProjectsCreatorTest extends GerritBaseTests {
+public class AllProjectsCreatorTest {
   private static final LabelType TEST_LABEL =
       new LabelType(
           "Test-Label",
@@ -127,7 +127,7 @@
     allProjectsCreator.create(allProjectsInput);
 
     Config config = readAllProjectsConfig(repoManager, allProjectsName);
-    assertThat(config.getString("project", null, "description")).isEqualTo(testDescription);
+    assertThat(config).stringValue("project", null, "description").isEqualTo(testDescription);
   }
 
   @Test
@@ -143,7 +143,7 @@
     allProjectsCreator.create(allProjectsInput);
 
     Config config = readAllProjectsConfig(repoManager, allProjectsName);
-    assertThat(config.getBoolean("submit", null, "rejectEmptyCommit", false)).isTrue();
+    assertThat(config).booleanValue("submit", null, "rejectEmptyCommit", false).isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 5c1b201..059b7f3 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -15,15 +15,15 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.schema.NoteDbSchemaUpdater.requiredUpgrades;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.notedb.IntBlob;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
@@ -44,7 +43,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-public class NoteDbSchemaUpdaterTest extends GerritBaseTests {
+public class NoteDbSchemaUpdaterTest {
   @Test
   public void requiredUpgradesFromNoVersion() throws Exception {
     assertThat(requiredUpgrades(0, versions(10))).containsExactly(10).inOrder();
@@ -62,26 +61,20 @@
 
   @Test
   public void downgradeNotSupported() throws Exception {
-    try {
-      requiredUpgrades(14, versions(10, 11, 12, 13));
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .contains("Cannot downgrade NoteDb schema from version 14 to 13");
-    }
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> requiredUpgrades(14, versions(10, 11, 12, 13)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot downgrade NoteDb schema from version 14 to 13");
   }
 
   @Test
   public void skipToFirstVersionNotSupported() throws Exception {
     ImmutableSortedSet<Integer> versions = versions(10, 11, 12);
     assertThat(requiredUpgrades(9, versions)).containsExactly(10, 11, 12).inOrder();
-    try {
-      requiredUpgrades(8, versions);
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e).hasMessageThat().contains("Cannot skip NoteDb schema from version 8 to 10");
-    }
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> requiredUpgrades(8, versions));
+    assertThat(thrown).hasMessageThat().contains("Cannot skip NoteDb schema from version 8 to 10");
   }
 
   private static class TestUpdate {
@@ -231,12 +224,8 @@
             seedGroupSequenceRef();
           }
         };
-    try {
-      u.update();
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e).hasMessageThat().contains("NoteDb change migration was not completed");
-    }
+    StorageException thrown = assertThrows(StorageException.class, () -> u.update());
+    assertThat(thrown).hasMessageThat().contains("NoteDb change migration was not completed");
     assertThat(u.getMessages()).isEmpty();
     assertThat(u.readVersion()).isEmpty();
   }
@@ -250,12 +239,8 @@
             setNotesMigrationConfig();
           }
         };
-    try {
-      u.update();
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e).hasMessageThat().contains("upgrade to 2.16.x first");
-    }
+    StorageException thrown = assertThrows(StorageException.class, () -> u.update());
+    assertThat(thrown).hasMessageThat().contains("upgrade to 2.16.x first");
     assertThat(u.getMessages()).isEmpty();
     assertThat(u.readVersion()).isEmpty();
   }
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
index 9c62d7f..38e19f7 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
@@ -15,20 +15,19 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+import static com.google.gerrit.entities.RefNames.REFS_VERSION;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
-public class NoteDbSchemaVersionManagerTest extends GerritBaseTests {
+public class NoteDbSchemaVersionManagerTest {
   private NoteDbSchemaVersionManager manager;
   private TestRepository<?> tr;
 
@@ -55,14 +54,10 @@
   public void readInvalid() throws Exception {
     ObjectId blobId = tr.blob(" 1 2 3 ");
     tr.update(REFS_VERSION, blobId);
-    try {
-      manager.read();
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("invalid value in refs/meta/version blob at " + blobId.name());
-    }
+    StorageException thrown = assertThrows(StorageException.class, () -> manager.read());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("invalid value in refs/meta/version blob at " + blobId.name());
   }
 
   @Test
@@ -81,13 +76,9 @@
   @Test
   public void incrementWrongOldVersion() throws Exception {
     tr.update(REFS_VERSION, tr.blob("123"));
-    try {
-      manager.increment(456);
-      assert_().fail("expected StorageException");
-    } catch (StorageException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Expected old version 456 for refs/meta/version, found 123");
-    }
+    StorageException thrown = assertThrows(StorageException.class, () -> manager.increment(456));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Expected old version 456 for refs/meta/version, found 123");
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
index 7bc3848..31697fd 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
@@ -24,11 +24,10 @@
 import com.google.common.collect.Streams;
 import com.google.common.reflect.ClassPath;
 import com.google.common.reflect.ClassPath.ClassInfo;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.stream.IntStream;
 import org.junit.Test;
 
-public class NoteDbSchemaVersionsTest extends GerritBaseTests {
+public class NoteDbSchemaVersionsTest {
   @Test
   public void testGuessVersion() {
     assertThat(guessVersion(getClass())).isEmpty();
diff --git a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
index c5c956c..da22f76 100644
--- a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
+++ b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -103,7 +103,7 @@
       return factory
           .read(
               new MetaDataUpdate(
-                  GitReferenceUpdated.DISABLED, new Project.NameKey(ALL_PROJECTS), repo, null))
+                  GitReferenceUpdated.DISABLED, Project.nameKey(ALL_PROJECTS), repo, null))
           .getConfig();
     }
   }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 35f580c..c92a8e0 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -36,7 +35,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class SchemaCreatorImplTest extends GerritBaseTests {
+public class SchemaCreatorImplTest {
   @Inject private AllProjectsName allProjects;
 
   @Inject private GitRepositoryManager repoManager;
@@ -82,7 +81,7 @@
   private void assertValueRange(LabelType label, Integer... range) {
     List<Integer> rangeList = Arrays.asList(range);
     assertThat(rangeList).isNotEmpty();
-    assertThat(rangeList).isStrictlyOrdered();
+    assertThat(rangeList).isInStrictOrder();
 
     assertThat(label.getValues().stream().map(v -> (int) v.getValue()))
         .containsExactlyElementsIn(rangeList)
diff --git a/javatests/com/google/gerrit/server/schema/TestGroup.java b/javatests/com/google/gerrit/server/schema/TestGroup.java
index 4627e8b..cca6d6c 100644
--- a/javatests/com/google/gerrit/server/schema/TestGroup.java
+++ b/javatests/com/google/gerrit/server/schema/TestGroup.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.schema;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -47,7 +47,7 @@
     public abstract Builder setNameKey(AccountGroup.NameKey nameKey);
 
     public Builder setName(String name) {
-      return setNameKey(new AccountGroup.NameKey(name));
+      return setNameKey(AccountGroup.nameKey(name));
     }
 
     public abstract Builder setGroupUuid(AccountGroup.UUID uuid);
@@ -66,10 +66,9 @@
 
     public AccountGroup build() {
       TestGroup testGroup = autoBuild();
-      AccountGroup.NameKey name = testGroup.getNameKey().orElse(new AccountGroup.NameKey("users"));
-      AccountGroup.Id id = testGroup.getId().orElse(new AccountGroup.Id(Math.abs(name.hashCode())));
-      AccountGroup.UUID uuid =
-          testGroup.getGroupUuid().orElse(new AccountGroup.UUID(name + "-UUID"));
+      AccountGroup.NameKey name = testGroup.getNameKey().orElse(AccountGroup.nameKey("users"));
+      AccountGroup.Id id = testGroup.getId().orElse(AccountGroup.id(Math.abs(name.hashCode())));
+      AccountGroup.UUID uuid = testGroup.getGroupUuid().orElse(AccountGroup.uuid(name + "-UUID"));
       Timestamp createdOn = testGroup.getCreatedOn().orElseGet(TimeUtil::nowTs);
       AccountGroup accountGroup = new AccountGroup(name, id, uuid, createdOn);
       testGroup.getOwnerGroupUuid().ifPresent(accountGroup::setOwnerGroupUUID);
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index c98b35f..d1030b5 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -4,15 +4,23 @@
     name = "small_tests",
     size = "small",
     srcs = glob(["*.java"]),
+    runtime_deps = [
+        "//java/com/google/gerrit/lucene",
+        "//prolog:gerrit-prolog-common",
+    ],
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index cca844e..a2f800a 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -14,36 +14,67 @@
 
 package com.google.gerrit.server.update;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.notedb.TooManyUpdatesException;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
-public class BatchUpdateTest extends GerritBaseTests {
-  @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
+public class BatchUpdateTest {
+  private static final int MAX_UPDATES = 4;
 
-  @Inject private GitRepositoryManager repoManager;
+  @Rule
+  public InMemoryTestEnvironment testEnvironment =
+      new InMemoryTestEnvironment(
+          () -> {
+            Config cfg = new Config();
+            cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            return cfg;
+          });
+
   @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
+  @Inject private Sequences sequences;
 
   private Project.NameKey project;
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
-    project = new Project.NameKey("test");
+    project = Project.nameKey("test");
 
     Repository inMemoryRepo = repoManager.createRepository(project);
     repo = new TestRepository<>(inMemoryRepo);
@@ -68,4 +99,220 @@
     assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId())
         .isEqualTo(branchCommit.getId());
   }
+
+  @Test
+  public void cannotExceedMaxUpdates() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Excessive update"));
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void cannotExceedMaxUpdatesCountingMultipleChangeUpdatesInSingleBatch() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES - 1);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage("No-op");
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+  // Not possible to write a variant of this test that submits first and adds a message second in
+  // the same batch, since submit always comes last.
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+              update.setChangeMessage("Abandon");
+              update.setStatus(Change.Status.ABANDONED);
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
+    checkArgument(totalUpdates > 0);
+    checkArgument(totalUpdates <= MAX_UPDATES);
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(1);
+    for (int i = 2; i <= totalUpdates; i++) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+        bu.addOp(id, new AddMessageOp("Update " + i));
+        bu.execute();
+      }
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private Change.Id createChangeWithTwoPatchSets(int totalUpdates) throws Exception {
+    Change.Id id = createChangeWithUpdates(totalUpdates - 1);
+    ChangeNotes notes = changeNotesFactory.create(project, id);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId = repo.amend(notes.getCurrentPatchSet().commitId()).message("PS2").create();
+      bu.addOp(
+          id,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(id, 2), commitId)
+              .setMessage("Add PS2"));
+      bu.execute();
+    }
+
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private static class AddMessageOp implements BatchUpdateOp {
+    private final String message;
+    @Nullable private final PatchSet.Id psId;
+
+    AddMessageOp(String message) {
+      this(message, null);
+    }
+
+    AddMessageOp(String message, PatchSet.Id psId) {
+      this.message = message;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      PatchSet.Id psIdToUpdate = psId;
+      if (psIdToUpdate == null) {
+        psIdToUpdate = ctx.getChange().currentPatchSetId();
+      } else {
+        checkState(
+            ctx.getNotes().getPatchSets().containsKey(psIdToUpdate),
+            "%s not in %s",
+            psIdToUpdate,
+            ctx.getNotes().getPatchSets().keySet());
+      }
+      ctx.getUpdate(psIdToUpdate).setChangeMessage(message);
+      return true;
+    }
+  }
+
+  private int getUpdateCount(Change.Id changeId) throws Exception {
+    return changeNotesFactory.create(project, changeId).getUpdateCount();
+  }
+
+  private ObjectId getMetaId(Change.Id changeId) throws Exception {
+    return repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId();
+  }
+
+  private static class SubmitOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      SubmitRecord sr = new SubmitRecord();
+      sr.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label cr = new SubmitRecord.Label();
+      cr.status = SubmitRecord.Label.Status.OK;
+      cr.appliedBy = ctx.getAccountId();
+      cr.label = "Code-Review";
+      sr.labels = ImmutableList.of(cr);
+      ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+      update.merge(new RequestId(), ImmutableList.of(sr));
+      update.setChangeMessage("Submitted");
+      return true;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index b41c66c..b37e302 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -18,8 +18,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -31,7 +30,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RepoViewTest extends GerritBaseTests {
+public class RepoViewTest {
   private static final String MASTER = "refs/heads/master";
   private static final String BRANCH = "refs/heads/branch";
 
@@ -42,7 +41,7 @@
   @Before
   public void setUp() throws Exception {
     InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
-    Project.NameKey project = new Project.NameKey("project");
+    Project.NameKey project = Project.nameKey("project");
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     tr.branch(MASTER).commit().create();
diff --git a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
index e702656..808eca8 100644
--- a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
+++ b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
@@ -17,11 +17,10 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashSet;
 import org.junit.Test;
 
-public class IdGeneratorTest extends GerritBaseTests {
+public class IdGeneratorTest {
   @Test
   public void test1234() {
     final HashSet<Integer> seen = new HashSet<>();
diff --git a/javatests/com/google/gerrit/server/util/LabelVoteTest.java b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
index 3048b75..bda99a8 100644
--- a/javatests/com/google/gerrit/server/util/LabelVoteTest.java
+++ b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.util;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.util.LabelVote.parse;
 import static com.google.gerrit.server.util.LabelVote.parseWithEquals;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LabelVoteTest extends GerritBaseTests {
+public class LabelVoteTest {
   @Test
   public void labelVoteParse() {
     assertLabelVoteEquals(parse("Code-Review-2"), "Code-Review", -2);
@@ -83,11 +82,6 @@
   }
 
   private void assertParseWithEqualsFails(String value) {
-    try {
-      parseWithEquals(value);
-      assert_().fail("expected IllegalArgumentException when parsing \"%s\"", value);
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(IllegalArgumentException.class, () -> parseWithEquals(value));
   }
 }
diff --git a/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java b/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
index 9ea17f3..025bf84 100644
--- a/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
+++ b/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class MostSpecificComparatorTest extends GerritBaseTests {
+public class MostSpecificComparatorTest {
 
   private MostSpecificComparator cmp;
 
diff --git a/javatests/com/google/gerrit/server/util/SocketUtilTest.java b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
index 018b8db..25114f9 100644
--- a/javatests/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.util;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.util.SocketUtil.hostname;
 import static com.google.gerrit.server.util.SocketUtil.isIPv6;
 import static com.google.gerrit.server.util.SocketUtil.parse;
 import static com.google.gerrit.server.util.SocketUtil.resolve;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.net.InetAddress.getByName;
 import static java.net.InetSocketAddress.createUnresolved;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
@@ -32,7 +33,7 @@
 import java.net.UnknownHostException;
 import org.junit.Test;
 
-public class SocketUtilTest extends GerritBaseTests {
+public class SocketUtilTest {
   @Test
   public void testIsIPv6() throws UnknownHostException {
     final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
@@ -105,16 +106,16 @@
 
   @Test
   public void testParseInvalidIPv6() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid IPv6: [:3");
-    parse("[:3", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("[:3", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid IPv6: [:3");
   }
 
   @Test
   public void testParseInvalidPort() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid port: localhost:A");
-    parse("localhost:A", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("localhost:A", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid port: localhost:A");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/util/git/BUILD b/javatests/com/google/gerrit/server/util/git/BUILD
index 0cb7b8a..883898f 100644
--- a/javatests/com/google/gerrit/server/util/git/BUILD
+++ b/javatests/com/google/gerrit/server/util/git/BUILD
@@ -8,20 +8,18 @@
     ),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server/util/git",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//java/com/google/gerrit/truth",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
index c5e683f..531785f 100644
--- a/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
+++ b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
@@ -17,20 +17,19 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class SubmoduleSectionParserTest extends GerritBaseTests {
+public class SubmoduleSectionParserTest {
   private static final String THIS_SERVER = "http://localhost/";
 
   @Test
   public void followMasterBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("proj");
+    Project.NameKey p = Project.nameKey("proj");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -40,7 +39,7 @@
             + p.get()
             + "\n"
             + "branch = master\n");
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -48,14 +47,14 @@
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
             new SubmoduleSubscription(
-                targetBranch, new Branch.NameKey(p, "master"), "localpath-to-a"));
+                targetBranch, BranchNameKey.create(p, "master"), "localpath-to-a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void followMatchingBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -66,32 +65,32 @@
             + "\n"
             + "branch = .\n");
 
-    Branch.NameKey targetBranch1 = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch1 = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res1 =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch1).parseAllSections();
 
     Set<SubmoduleSubscription> expected1 =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch1, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch1, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res1).containsExactlyElementsIn(expected1);
 
-    Branch.NameKey targetBranch2 = new Branch.NameKey(new Project.NameKey("project"), "somebranch");
+    BranchNameKey targetBranch2 = BranchNameKey.create(Project.nameKey("project"), "somebranch");
 
     Set<SubmoduleSubscription> res2 =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch2).parseAllSections();
 
     Set<SubmoduleSubscription> expected2 =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch2, new Branch.NameKey(p, "somebranch"), "a"));
+            new SubmoduleSubscription(targetBranch2, BranchNameKey.create(p, "somebranch"), "a"));
 
     assertThat(res2).containsExactlyElementsIn(expected2);
   }
 
   @Test
   public void followAnotherBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -102,21 +101,21 @@
             + "\n"
             + "branch = anotherbranch\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "anotherbranch"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "anotherbranch"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withAnotherURI() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -127,21 +126,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSlashesInProjectName() throws Exception {
-    Project.NameKey p = new Project.NameKey("project/with/slashes/a");
+    Project.NameKey p = Project.nameKey("project/with/slashes/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -152,21 +151,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSlashesInPath() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -177,22 +176,23 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a/b/c/d/e"));
+            new SubmoduleSubscription(
+                targetBranch, BranchNameKey.create(p, "master"), "a/b/c/d/e"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withMoreSections() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
-    Project.NameKey p2 = new Project.NameKey("b");
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -209,23 +209,23 @@
             + "\n"
             + "		branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSubProjectFound() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a/b");
-    Project.NameKey p2 = new Project.NameKey("b");
+    Project.NameKey p1 = Project.nameKey("a/b");
+    Project.NameKey p2 = Project.nameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -242,25 +242,25 @@
             + "\n"
             + "branch = .\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a/b"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a/b"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withAnInvalidSection() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
-    Project.NameKey p2 = new Project.NameKey("b");
-    Project.NameKey p3 = new Project.NameKey("d");
-    Project.NameKey p4 = new Project.NameKey("e");
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
+    Project.NameKey p3 = Project.nameKey("d");
+    Project.NameKey p4 = Project.nameKey("e");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -293,15 +293,15 @@
             + "\n"
             + "    branch = refs/heads/master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p4, "master"), "e"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p4, "master"), "e"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
@@ -317,7 +317,7 @@
             // Project "a" doesn't exist
             + "branch = .\\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -327,7 +327,7 @@
 
   @Test
   public void withSectionToOtherServer() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -338,7 +338,7 @@
             + "\n"
             + "branch = .");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -348,7 +348,7 @@
 
   @Test
   public void withRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -359,21 +359,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -384,22 +384,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withOverlyDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("nested/a");
+    Project.NameKey p1 = Project.nameKey("nested/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -410,15 +409,14 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
diff --git a/javatests/com/google/gerrit/sshd/BUILD b/javatests/com/google/gerrit/sshd/BUILD
index c010d7c..3e11ff2 100644
--- a/javatests/com/google/gerrit/sshd/BUILD
+++ b/javatests/com/google/gerrit/sshd/BUILD
@@ -6,7 +6,6 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/sshd",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/mina:sshd",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
index b663849..777cb4f 100644
--- a/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
+++ b/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
@@ -17,13 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.extensions.api.projects.ConfigValue;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collections;
 import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProjectConfigParamParserTest extends GerritBaseTests {
+public class ProjectConfigParamParserTest {
 
   private CreateProjectCommand cmd;
 
diff --git a/javatests/com/google/gerrit/testing/GerritJUnitTest.java b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
index 430f48f..56dda08 100644
--- a/javatests/com/google/gerrit/testing/GerritJUnitTest.java
+++ b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.testing;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import org.junit.Test;
@@ -68,7 +68,7 @@
           () -> {
             throw new MyException("foo");
           });
-      assert_().fail("expected AssertionError");
+      assertWithMessage("expected AssertionError").fail();
     } catch (AssertionError e) {
       assertThat(e).hasMessageThat().contains(IllegalStateException.class.getSimpleName());
       assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
@@ -81,7 +81,7 @@
   public void assertThrowsThrowsAssertionErrorWhenNothingThrown() {
     try {
       assertThrows(MyException.class, () -> {});
-      assert_().fail("expected AssertionError");
+      assertWithMessage("expected AssertionError").fail();
     } catch (AssertionError e) {
       assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
       assertThat(e).hasCauseThat().isNull();
diff --git a/javatests/com/google/gerrit/testing/IndexVersionsTest.java b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
index 36247f8..0362ddc 100644
--- a/javatests/com/google/gerrit/testing/IndexVersionsTest.java
+++ b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.IndexVersions.ALL;
 import static com.google.gerrit.testing.IndexVersions.CURRENT;
 import static com.google.gerrit.testing.IndexVersions.PREVIOUS;
@@ -25,7 +26,7 @@
 import java.util.List;
 import org.junit.Test;
 
-public class IndexVersionsTest extends GerritBaseTests {
+public class IndexVersionsTest {
   private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
 
   @Test
@@ -133,8 +134,8 @@
   }
 
   private void assertIllegalArgument(String value, String expectedMessage) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(expectedMessage);
-    get(value);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> get(value));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/util/http/BUILD b/javatests/com/google/gerrit/util/http/BUILD
index 2999fc0..4711faa 100644
--- a/javatests/com/google/gerrit/util/http/BUILD
+++ b/javatests/com/google/gerrit/util/http/BUILD
@@ -4,12 +4,10 @@
     name = "http_tests",
     srcs = glob(["**/*.java"]),
     deps = [
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/util/http",
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
-        "//lib:servlet-api-3_1-without-neverlink",
-        "//lib/easymock",
+        "//lib:servlet-api-without-neverlink",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
index adda5e7..bef9d4b1 100644
--- a/javatests/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
@@ -18,11 +18,10 @@
 import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
 import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import org.junit.Test;
 
-public class RequestUtilTest extends GerritBaseTests {
+public class RequestUtilTest {
   @Test
   public void getEncodedPathInfo_emptyContextPath() {
     assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index 6acc7ca..3a67d45 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -7,8 +7,8 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:guava",
-        "//lib:servlet-api-3_1",
+        "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/httpcomponents:httpclient",
-        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/lib/BUILD b/lib/BUILD
index 7271026..2e5668e 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -14,18 +14,18 @@
 )
 
 java_library(
-    name = "servlet-api-3_1",
+    name = "servlet-api",
     data = ["//lib:LICENSE-Apache2.0"],
     neverlink = 1,
     visibility = ["//visibility:public"],
-    exports = ["@servlet-api-3_1//jar"],
+    exports = ["@servlet-api//jar"],
 )
 
 java_library(
-    name = "servlet-api-3_1-without-neverlink",
+    name = "servlet-api-without-neverlink",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@servlet-api-3_1//jar"],
+    exports = ["@servlet-api//jar"],
 )
 
 java_library(
@@ -36,6 +36,49 @@
 )
 
 java_library(
+    name = "jgit",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit:jgit"],
+    runtime_deps = [
+        ":javaewah",
+        "//lib/log:api",
+    ],
+)
+
+java_library(
+    name = "jgit-archive",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.archive:jgit-archive"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "jgit-junit",
+    testonly = True,
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.junit:junit"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "jgit-servlet",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.http.server:jgit-servlet"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "javaewah",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@javaewah//jar"],
+)
+
+java_library(
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
@@ -71,6 +114,7 @@
     name = "caffeine",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
+        "//java/com/google/gerrit/acceptance:__pkg__",
         "//java/com/google/gerrit/server/cache/mem:__pkg__",
     ],
     exports = ["@caffeine//jar"],
@@ -85,6 +129,7 @@
     name = "caffeine-guava",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
+        "//java/com/google/gerrit/acceptance:__pkg__",
         "//java/com/google/gerrit/server/cache/mem:__pkg__",
     ],
     exports = [":caffeine-guava-renamed"],
@@ -442,13 +487,6 @@
 )
 
 java_library(
-    name = "javassist",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = ["@javassist//jar"],
-)
-
-java_library(
     name = "soy",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -485,6 +523,18 @@
     exports = ["@icu4j//jar"],
 )
 
+java_library(
+    name = "javax-annotation",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = [
+        "//java/com/google/gerrit/acceptance:__pkg__",
+        "//java/com/google/gerrit/extensions:__pkg__",
+        "//java/com/google/gerrit/server:__pkg__",
+        "//plugins:__pkg__",
+    ],
+    exports = ["@javax-annotation//jar"],
+)
+
 sh_test(
     name = "nongoogle_test",
     srcs = ["nongoogle_test.sh"],
diff --git a/lib/LICENSE-shadycss b/lib/LICENSE-shadycss
new file mode 100644
index 0000000..0fe5c52
--- /dev/null
+++ b/lib/LICENSE-shadycss
@@ -0,0 +1,20 @@
+# License
+
+Everything in this repo is BSD style license unless otherwise specified.
+
+Copyright (c) 2015 The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+* Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
deleted file mode 100644
index 90c9673..0000000
--- a/lib/easymock/BUILD
+++ /dev/null
@@ -1,26 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "easymock",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = ["@easymock//jar"],
-    runtime_deps = [
-        ":cglib-3_2",
-        ":objenesis",
-    ],
-)
-
-java_library(
-    name = "cglib-3_2",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = ["@cglib-3_2//jar"],
-)
-
-java_library(
-    name = "objenesis",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = ["@objenesis//jar"],
-)
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD
new file mode 100644
index 0000000..456860a
--- /dev/null
+++ b/lib/errorprone/BUILD
@@ -0,0 +1,8 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "annotations",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@error-prone-annotations//jar"],
+)
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
index e8845e2..68da16a 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -17,7 +17,7 @@
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@greenmail//jar"],
     runtime_deps = select({
-        "//:java9": POST_JDK8_DEPS,
+        "//:java11": POST_JDK8_DEPS,
         "//:java_next": POST_JDK8_DEPS,
         "//conditions:default": [],
     }),
diff --git a/lib/guava.bzl b/lib/guava.bzl
index c36bf14..18a8355 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "27.1-jre"
+GUAVA_VERSION = "28.1-jre"
 
-GUAVA_BIN_SHA1 = "e47b59c893079b87743cdcfb6f17ca95c08c592c"
+GUAVA_BIN_SHA1 = "b0e91dcb6a44ffb6221b5027e12a5cb34b841145"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index 3eed77a..d5253a0 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -4,6 +4,7 @@
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
+        "//java/com/google/gerrit/acceptance:__pkg__",
         "//java/com/google/gerrit/elasticsearch:__pkg__",
         "//plugins:__pkg__",
     ],
diff --git a/lib/jgit/BUILD b/lib/jgit/BUILD
deleted file mode 100644
index e69de29..0000000
--- a/lib/jgit/BUILD
+++ /dev/null
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
deleted file mode 100644
index 7ebd2ca..0000000
--- a/lib/jgit/jgit.bzl
+++ /dev/null
@@ -1,75 +0,0 @@
-load("//tools/bzl:maven_jar.bzl", "MAVEN_CENTRAL", "maven_jar")
-
-_JGIT_VERS = "5.3.7.202002110540-r"
-
-_DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
-
-JGIT_DOC_URL = "https://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
-
-_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
-
-# set this to use a local version.
-# "/home/<user>/projects/jgit"
-LOCAL_JGIT_REPO = ""
-
-def jgit_repos():
-    if LOCAL_JGIT_REPO:
-        native.local_repository(
-            name = "jgit",
-            path = LOCAL_JGIT_REPO,
-        )
-        jgit_maven_repos_dev()
-    else:
-        jgit_maven_repos()
-
-def jgit_maven_repos_dev():
-    # Transitive dependencies from JGit's WORKSPACE.
-    maven_jar(
-        name = "hamcrest-library",
-        artifact = "org.hamcrest:hamcrest-library:1.3",
-        sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-    )
-    maven_jar(
-        name = "jzlib",
-        artifact = "com.jcraft:jzlib:1.1.1",
-        sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
-    )
-
-def jgit_maven_repos():
-    maven_jar(
-        name = "jgit-lib",
-        artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
-        repository = _JGIT_REPO,
-        sha1 = "b1714d4917750d6fad0d19d3b0e258b373db819a",
-    )
-    maven_jar(
-        name = "jgit-servlet",
-        artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
-        repository = _JGIT_REPO,
-        sha1 = "cf61e6e00a758a6f33995e53883aede76d3b2400",
-    )
-    maven_jar(
-        name = "jgit-archive",
-        artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
-        repository = _JGIT_REPO,
-        sha1 = "3c0b259040d3bc3a9e884a301055cf4f2e1bb1e2",
-    )
-    maven_jar(
-        name = "jgit-junit",
-        artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
-        repository = _JGIT_REPO,
-        sha1 = "f78409fb808c5a108c629ec3cba74cc6c14ebff2",
-    )
-
-def jgit_dep(name):
-    mapping = {
-        "@jgit-archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
-        "@jgit-junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
-        "@jgit-lib//jar": "@jgit//org.eclipse.jgit:jgit",
-        "@jgit-servlet//jar": "@jgit//org.eclipse.jgit.http.server:jgit-servlet",
-    }
-
-    if LOCAL_JGIT_REPO:
-        return mapping[name]
-    else:
-        return name
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
deleted file mode 100644
index 151cd71..0000000
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//lib/jgit:jgit.bzl", "jgit_dep")
-
-java_library(
-    name = "jgit-archive",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit-archive//jar")],
-    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
-)
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
deleted file mode 100644
index fd634a5..0000000
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//lib/jgit:jgit.bzl", "jgit_dep")
-
-java_library(
-    name = "jgit-servlet",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit-servlet//jar")],
-    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
-)
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
deleted file mode 100644
index abc522b..0000000
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ /dev/null
@@ -1,11 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//lib/jgit:jgit.bzl", "jgit_dep")
-
-java_library(
-    name = "junit",
-    testonly = True,
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit-junit//jar")],
-    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
-)
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
deleted file mode 100644
index c1f2607..0000000
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ /dev/null
@@ -1,20 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//lib/jgit:jgit.bzl", "jgit_dep")
-
-java_library(
-    name = "jgit",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit-lib//jar")],
-    runtime_deps = [
-        ":javaewah",
-        "//lib/log:api",
-    ],
-)
-
-java_library(
-    name = "javaewah",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@javaewah//jar"],
-)
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 1597c02..1cd8d52 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -34,44 +34,44 @@
     bower_archive(
         name = "iron-a11y-announcer",
         package = "PolymerElements/iron-a11y-announcer",
-        version = "1.0.6",
-        sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc",
+        version = "2.1.0",
+        sha1 = "bda12ed6fe7b98a64bf5f70f3e84384053763190",
     )
     bower_archive(
         name = "iron-a11y-keys-behavior",
         package = "PolymerElements/iron-a11y-keys-behavior",
-        version = "1.1.9",
-        sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465",
+        version = "2.1.1",
+        sha1 = "4c8f303479253301e81c63b8ba7bd4cfb62ddf55",
     )
     bower_archive(
         name = "iron-behaviors",
         package = "PolymerElements/iron-behaviors",
-        version = "1.0.18",
-        sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18",
+        version = "2.1.1",
+        sha1 = "d2418e886c3237dcbc8d74a956eec367a95cd068",
     )
     bower_archive(
         name = "iron-checked-element-behavior",
         package = "PolymerElements/iron-checked-element-behavior",
-        version = "1.0.6",
-        sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370",
+        version = "2.1.1",
+        sha1 = "822b6c73e349cf5174e3a17aa9b3d2cb823c37ac",
     )
     bower_archive(
         name = "iron-fit-behavior",
         package = "PolymerElements/iron-fit-behavior",
-        version = "1.2.7",
-        sha1 = "01c485fbf898307029bbb72ac7e132db1570a842",
+        version = "2.2.1",
+        sha1 = "7b12bc96bf05f04bbb6ad78a16d6c39758263a14",
     )
     bower_archive(
         name = "iron-flex-layout",
         package = "PolymerElements/iron-flex-layout",
-        version = "1.3.9",
-        sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796",
+        version = "2.0.3",
+        sha1 = "c88e9577cabb005ea6d33f35b97d9c39c68f3d9e",
     )
     bower_archive(
         name = "iron-form-element-behavior",
         package = "PolymerElements/iron-form-element-behavior",
-        version = "1.0.7",
-        sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12",
+        version = "2.1.3",
+        sha1 = "634f01cdedd7a616ae025fdcde85c6c5804f6377",
     )
     bower_archive(
         name = "iron-menu-behavior",
@@ -82,20 +82,20 @@
     bower_archive(
         name = "iron-meta",
         package = "PolymerElements/iron-meta",
-        version = "1.1.3",
-        sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041",
+        version = "2.1.1",
+        sha1 = "7985a9f18b6c32d62f5d3870d58d73ef66613cb9",
     )
     bower_archive(
         name = "iron-resizable-behavior",
-        package = "polymerelements/iron-resizable-behavior",
-        version = "1.0.6",
-        sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e",
+        package = "PolymerElements/iron-resizable-behavior",
+        version = "2.1.1",
+        sha1 = "31e32da6880a983da32da21ee3f483525b24e458",
     )
     bower_archive(
         name = "iron-validatable-behavior",
         package = "PolymerElements/iron-validatable-behavior",
-        version = "1.1.2",
-        sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be",
+        version = "2.1.0",
+        sha1 = "b5dcf3bf4d95b074b74f8170d7122d34ab417daf",
     )
     bower_archive(
         name = "lodash",
@@ -111,15 +111,15 @@
     )
     bower_archive(
         name = "neon-animation",
-        package = "polymerelements/neon-animation",
-        version = "1.2.5",
-        sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8",
+        package = "PolymerElements/neon-animation",
+        version = "2.2.1",
+        sha1 = "865f4252c6306b91609769fefefb4f641361931f",
     )
     bower_archive(
         name = "paper-behaviors",
         package = "PolymerElements/paper-behaviors",
-        version = "1.0.13",
-        sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7",
+        version = "2.1.1",
+        sha1 = "af59936a9015cda4abcfb235f831090a41faa2c4",
     )
     bower_archive(
         name = "paper-icon-button",
@@ -130,16 +130,22 @@
     bower_archive(
         name = "paper-ripple",
         package = "PolymerElements/paper-ripple",
-        version = "1.0.10",
-        sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893",
+        version = "2.1.1",
+        sha1 = "d402c8165c6a09d17c12a2b421e69ea54e2fc8ef",
     )
     bower_archive(
         name = "paper-styles",
         package = "PolymerElements/paper-styles",
-        # Basically 1.3.1 but with
-        # https://github.com/PolymerElements/paper-styles/pull/164 applied
-        version = "dd0b13e186b9690d5e74a93f6e51e0835ea60495",
-        sha1 = "f859a8dee403fbb724e8d0cf009db79c6dd61b47",
+        # Basically 2.1.0 but with
+        # https://github.com/PolymerElements/paper-styles/pull/165 applied
+        version = "a6c207e6eee3402fd7a6550e6f9c387ca22ec4c4",
+        sha1 = "6bd17410578b5d4017ccef330393a4b41b1c716e",
+    )
+    bower_archive(
+        name = "shadycss",
+        package = "webcomponents/shadycss",
+        version = "1.9.1",
+        sha1 = "3ef3bd54280ea2d7ce90434620354a2022c8e13d",
     )
     bower_archive(
         name = "sinon-chai",
@@ -160,14 +166,8 @@
         sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb",
     )
     bower_archive(
-        name = "web-animations-js",
-        package = "web-animations/web-animations-js",
-        version = "2.3.1",
-        sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e",
-    )
-    bower_archive(
         name = "webcomponentsjs",
         package = "webcomponents/webcomponentsjs",
-        version = "0.7.24",
-        sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c",
+        version = "1.3.3",
+        sha1 = "bbad90bd8301a2f2f5e014e750e0c86351579391",
     )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index 64ab611..7fd61c7 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -77,7 +77,6 @@
         deps = [
             ":iron-behaviors",
             ":iron-overlay-behavior",
-            ":iron-resizable-behavior",
             ":neon-animation",
             ":polymer",
         ],
@@ -195,11 +194,9 @@
         name = "neon-animation",
         license = "//lib:LICENSE-polymer",
         deps = [
-            ":iron-meta",
             ":iron-resizable-behavior",
             ":iron-selector",
             ":polymer",
-            ":web-animations-js",
         ],
     )
     bower_component(
@@ -331,14 +328,15 @@
     bower_component(
         name = "polymer",
         license = "//lib:LICENSE-polymer",
-        deps = [":webcomponentsjs"],
+        deps = [
+            ":shadycss",
+            ":webcomponentsjs",
+        ],
         seed = True,
     )
     bower_component(
-        name = "promise-polyfill",
-        license = "//lib:LICENSE-promise-polyfill",
-        deps = [":polymer"],
-        seed = True,
+        name = "shadycss",
+        license = "//lib:LICENSE-shadycss",
     )
     bower_component(
         name = "sinon-chai",
@@ -358,10 +356,6 @@
         seed = True,
     )
     bower_component(
-        name = "web-animations-js",
-        license = "//lib:LICENSE-Apache2.0",
-    )
-    bower_component(
         name = "web-component-tester",
         license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
         deps = [
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
index 8a9e1ee..5a6a8c0 100644
--- a/lib/js/npm.bzl
+++ b/lib/js/npm.bzl
@@ -1,11 +1,11 @@
 NPM_VERSIONS = {
     "bower": "1.8.8",
     "crisper": "2.0.2",
-    "polymer-bundler": "4.0.2",
+    "polymer-bundler": "4.0.9",
 }
 
 NPM_SHA1S = {
     "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
     "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "polymer-bundler": "6b296b6099ab5a0e93ca914cbe93e753f2395910",
+    "polymer-bundler": "c80c9815690d76656d1fa6a231481850b4fa3874",
 }
diff --git a/lib/log/BUILD b/lib/log/BUILD
index b119b94..fa5bc45 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -5,7 +5,7 @@
     data = ["//lib:LICENSE-slf4j"],
     visibility = [
         "//javatests/com/google/gerrit/elasticsearch:__pkg__",
-        "//lib/jgit/org.eclipse.jgit:__pkg__",
+        "//lib:__pkg__",
         "//plugins:__pkg__",
     ],
     exports = ["@log-api//jar"],
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 5ad47cd..2f98ee3 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     exports = [
         ":eddsa",
+        "@sshd-common//jar",
         "@sshd-mina//jar",
         "@sshd//jar",
     ],
diff --git a/lib/mockito/BUILD b/lib/mockito/BUILD
index cff36cd..2aaf56d 100644
--- a/lib/mockito/BUILD
+++ b/lib/mockito/BUILD
@@ -8,8 +8,7 @@
 java_library(
     name = "mockito",
     data = ["//lib:LICENSE-mockito"],
-    # Only exposed for plugin tests; core tests should use Easymock
-    visibility = ["//java/com/google/gerrit/acceptance:__pkg__"],
+    visibility = ["//visibility:public"],
     exports = ["@mockito//jar"],
     runtime_deps = [
         ":byte-buddy",
@@ -21,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/nongoogle_test.sh b/lib/nongoogle_test.sh
index 492c603..336fcbf 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -20,20 +20,14 @@
 httpcore-nio
 j2objc
 jackson-core
-javassist
 jna
 jruby
 mina-core
 nekohtml
 objenesis
 openid-consumer
-powermock-api-easymock
-powermock-api-support
-powermock-core
-powermock-module-junit4
-powermock-module-junit4-common
-powermock-reflect
 sshd
+sshd-common
 sshd-mina
 testcontainers
 testcontainers-elasticsearch
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
deleted file mode 100644
index 39df164..0000000
--- a/lib/powermock/BUILD
+++ /dev/null
@@ -1,69 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "powermock-module-junit4",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":powermock-module-junit4-common",
-        "//lib:junit",
-        "@powermock-module-junit4//jar",
-    ],
-)
-
-java_library(
-    name = "powermock-module-junit4-common",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":powermock-reflect",
-        "//lib:junit",
-        "@powermock-module-junit4-common//jar",
-    ],
-)
-
-java_library(
-    name = "powermock-reflect",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        "//lib:junit",
-        "//lib/easymock:objenesis",
-        "@powermock-reflect//jar",
-    ],
-)
-
-java_library(
-    name = "powermock-api-easymock",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":powermock-api-support",
-        "//lib/easymock",
-        "@powermock-api-easymock//jar",
-    ],
-)
-
-java_library(
-    name = "powermock-api-support",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":powermock-core",
-        ":powermock-reflect",
-        "//lib:junit",
-        "@powermock-api-support//jar",
-    ],
-)
-
-java_library(
-    name = "powermock-core",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":powermock-reflect",
-        "//lib:javassist",
-        "//lib:junit",
-        "@powermock-core//jar",
-    ],
-)
diff --git a/modules/jgit b/modules/jgit
new file mode 160000
index 0000000..a79c5b1
--- /dev/null
+++ b/modules/jgit
@@ -0,0 +1 @@
+Subproject commit a79c5b1f1046f4b41928bc40ab433155168dacc6
diff --git a/package.json b/package.json
index db09e9c..56acf61 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "gerrit",
-  "version": "3.0.7-SNAPSHOT",
+  "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
diff --git a/plugins/BUILD b/plugins/BUILD
index 3ec5404..5f9c142 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -44,7 +44,7 @@
     "//java/com/google/gerrit/mail",
     "//java/com/google/gerrit/metrics",
     "//java/com/google/gerrit/metrics/dropwizard",
-    "//java/com/google/gerrit/reviewdb:server",
+    "//java/com/google/gerrit/entities",
     "//java/com/google/gerrit/server/api",
     "//java/com/google/gerrit/server/audit",
     "//java/com/google/gerrit/server/cache/mem",
@@ -69,8 +69,8 @@
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
     "//lib/jackson:jackson-core",
-    "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
-    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib:jgit-servlet",
+    "//lib:jgit",
     "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
@@ -88,13 +88,24 @@
     "//lib:jsch",
     "//lib:mime-util",
     "//lib:protobuf",
-    "//lib:servlet-api-3_1-without-neverlink",
+    "//lib:servlet-api-without-neverlink",
     "//lib:soy",
     "//prolog:gerrit-prolog-common",
 ]
 
 java_binary(
+    name = "bouncycastle-deploy-env",
+    main_class = "Dummy",
+    runtime_deps = [
+        "//lib/bouncycastle:bcpg",
+        "//lib/bouncycastle:bcpkix",
+        "//lib/bouncycastle:bcprov",
+    ],
+)
+
+java_binary(
     name = "plugin-api",
+    deploy_env = ["bouncycastle-deploy-env"],
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [":plugin-lib"],
@@ -121,12 +132,12 @@
         "//antlr3:libquery_parser-src.jar",
         "//java/com/google/gerrit/common:libannotations-src.jar",
         "//java/com/google/gerrit/common:libserver-src.jar",
+        "//java/com/google/gerrit/entities:libentities-src.jar",
         "//java/com/google/gerrit/extensions:libapi-src.jar",
         "//java/com/google/gerrit/httpd:libhttpd-src.jar",
         "//java/com/google/gerrit/index:libindex-src.jar",
         "//java/com/google/gerrit/index:libquery_exception-src.jar",
         "//java/com/google/gerrit/pgm/init/api:libapi-src.jar",
-        "//java/com/google/gerrit/reviewdb:libserver-src.jar",
         "//java/com/google/gerrit/server:libserver-src.jar",
         "//java/com/google/gerrit/server/restapi:librestapi-src.jar",
         "//java/com/google/gerrit/sshd:libsshd-src.jar",
@@ -143,7 +154,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/util/http",
     ],
     pkgs = ["com.google.gerrit"],
diff --git a/plugins/delete-project b/plugins/delete-project
index 171704d..debaa2c 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 171704d1cdcd3ec281fbe90f7b5a9302b4b66866
+Subproject commit debaa2c6d66b5a3c1bc95bc09283344c88407d40
diff --git a/plugins/download-commands b/plugins/download-commands
index 41c61bf..1b98be8 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 41c61bf8c1869bff4e0b436f69478c2137d0ca07
+Subproject commit 1b98be8d371e68237182df0f04361017f2be2ea8
diff --git a/plugins/gitiles b/plugins/gitiles
index 8e0f5bd..dcac54b 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 8e0f5bdfb89371d0e3bf91f15a8fd8157821540e
+Subproject commit dcac54b1afe9275bdd0fc8f3afc2c019b6617ad1
diff --git a/plugins/hooks b/plugins/hooks
index 089687b..5678f93 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 089687bdcc64b003d09a77f00eaa77bb79b15b9c
+Subproject commit 5678f93fa62df1ec2baedc3a407aff71ca96556d
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index d3b2a6e..20bec50 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit d3b2a6eabcb641e952f253e61b927cd1f7f6e30c
+Subproject commit 20bec5084c7b90029b8860cbb2fb9a12928f6979
diff --git a/plugins/replication b/plugins/replication
index cb61577..5316fcb 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit cb61577630f52e90234f054bf596cc495e12f576
+Subproject commit 5316fcbd139760efa87e0bbf95ac110bc6117645
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index be50378..b9e1e8d 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit be5037839d987a319dac2236e9c1221d4d31848d
+Subproject commit b9e1e8d61a324ca0592bbb7c80c2802618ef5bc9
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 8a3b6fa..d04c4c3 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 8a3b6faeaebc0f9b7d1af19eb0022b32994e476a
+Subproject commit d04c4c33ad36e2e11ccc8b798357dd1e4e979a1a
diff --git a/plugins/webhooks b/plugins/webhooks
index 56ea65d..11b6fb8 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 56ea65dd137d5dc2211378af961a44eb9b4e8c18
+Subproject commit 11b6fb81b5e4fb0bf3909f862c120d20d4fa9aaf
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 0e9b4bb..1c685ab 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -31,7 +31,7 @@
         "//lib/js:paper-toggle-button",
         "//lib/js:polymer",
         "//lib/js:polymer-resin",
-        "//lib/js:promise-polyfill",
+        "//lib/js:shadycss",
     ],
 )
 
diff --git a/polygerrit-ui/Polymer2.md b/polygerrit-ui/Polymer2.md
new file mode 100644
index 0000000..96bf779
--- /dev/null
+++ b/polygerrit-ui/Polymer2.md
@@ -0,0 +1,15 @@
+## Polymer 2 upgrade
+
+Gerrit is updating to use polymer 2 from polymer 1 by following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade).
+
+Polymer 2 contains several breaking changes that may affect some of the UI features and plugins. One of the biggest change is to have the shadow DOM enabled. This will affect how you query elements inside of your component, how css style works within and across components, and several other usages.
+
+If you are owner of any plugins, please start following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade) to migrate your plugins to be polymer 2 ready.
+
+If you notice any issues or need help with anything, don't hesitate to report to us [here](https://bugs.chromium.org/p/gerrit/issues/list).
+
+
+### Related resources
+
+- [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade)
+- [Polymer Shadow DOM](https://polymer-library.polymer-project.org/2.0/docs/devguide/shadow-dom)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 4dbe146..1fbf581 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,4 +1,8 @@
-# PolyGerrit
+# Gerrit Polymer Frontend
+
+Follow the
+[setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+where applicable.
 
 ## Installing [Bazel](https://bazel.build/)
 
@@ -20,8 +24,8 @@
 brew install npm
 ```
 
-All other platforms: [download from
-nodejs.org](https://nodejs.org/en/download/).
+All other platforms:
+[download from nodejs.org](https://nodejs.org/en/download/).
 
 Various steps below require installing additional npm packages. The full list of
 dependencies can be installed with:
@@ -33,17 +37,12 @@
 It may complain about a missing `typescript@2.3.4` peer dependency, which is
 harmless.
 
-If you're interested in the details, keep reading.
+## Running locally against production data
 
-## Local UI, Production Data
+#### Go server
 
-This is a quick and easy way to test your local changes against real data.
-Unfortunately, you can't sign in, so testing certain features will require
-you to use the "test data" technique described below.
-
-### Running the server
-
-To test the local UI against gerrit-review.googlesource.com:
+To test the local Polymer frontend against gerrit-review.googlesource.com
+simply execute:
 
 ```sh
 ./polygerrit-ui/run-server.sh
@@ -51,39 +50,51 @@
 
 Then visit http://localhost:8081
 
-## Local UI, Test Data
-
-1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
-2. Set up a local test site. Docs
-   [here](https://gerrit-review.googlesource.com/Documentation/linux-quickstart.html) and
-   [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
-
-When your project is set up and works using the classic UI, run a test server
-that serves PolyGerrit:
-
-```sh
-bazel build gerrit &&
-  $(bazel info output_base)/external/local_jdk/bin/java -DsourceRoot=/path/to/my/checkout \
-  -jar bazel-bin/gerrit.war daemon --polygerrit-dev \
-  -d ../gerrit_testsite --console-log --show-stack-trace
-```
-
-Serving plugins
-
-> Local dev plugins must be put inside of gerrit/plugins
-
-Loading a single plugin file:
-
-```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
-
-Loading multiple plugin files:
+This method is based on a
+[simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
+Mostly it just switches between serving files locally and proxying the real
+server based on the file name. It also does some basic response rewriting, e.g.
+it patches the `config/server/info` response with plugin information provided on
+the command line:
 
 ```sh
 ./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
 ```
 
+The biggest draw back of this method is that you cannot log in, so cannot test
+scenarios that require it.
+
+#### MITM Proxy
+
+[MITM Proxy](https://mitmproxy.org/) is an open source product for proxying
+https servers. The
+[contrib/mitm-ui/](https://gerrit.googlesource.com/gerrit/+/master/contrib/mitm-ui/)
+directory contains scripts (and documentation) for using this technology
+(instead of the Go server). These scripts are somewhat experimental and
+unmaintained though.
+
+## Running locally against a Gerrit test site
+
+Set up a local test site once:
+
+1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
+2. [Set up a local test site](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
+3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py) your test site with some test data.
+
+For running a locally built Gerrit war against your test instance use
+[this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon),
+and add the `--polygerrit-dev` option, if you want to serve the Polymer frontend
+directly from the sources in `polygerrit_ui/app/` instead of from the war:
+
+```sh
+$(bazel info output_base)/external/local_jdk/bin/java \
+    -DsourceRoot=$(bazel info workspace) \
+    -jar bazel-bin/gerrit.war daemon \
+    -d $GERRIT_SITE \
+    --console-log \
+    --polygerrit-dev
+```
+
 ## Running Tests
 
 This step requires the `web-component-tester` npm module.
@@ -93,7 +104,7 @@
 
 For daily development you typically only want to run and debug individual tests.
 Run the local [Go proxy server](#go-server) and navigate for example to
-<http://localhost:8081/elements/change/gr-account-entry/gr-account-entry_test.html>.
+<http://localhost:8081/elements/shared/gr-account-entry/gr-account-entry_test.html>.
 Check "Disable cache" in the "Network" tab of Chrome's dev tools, so code
 changes are picked up on "reload".
 
@@ -115,11 +126,6 @@
 WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh
 ```
 
-Toolchain requirements for headless mode:
-
-* Chrome: 59+
-* web-component-tester: v6.5.0+
-
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
@@ -176,11 +182,14 @@
 ```
 
 ## Template Type Safety
-Polymer elements are not type checked against the element definition, making it trivial to break the display when refactoring or moving code. We now run additional tests to help ensure that template types are checked.
+Polymer elements are not type checked against the element definition, making it
+trivial to break the display when refactoring or moving code. We now run
+additional tests to help ensure that template types are checked.
 
 A few notes to ensure that these tests pass
 - Any functions with optional parameters will need closure annotations.
-- Any Polymer parameters that are nullable or can be multiple types (other than the one explicitly delared) will need type annotations.
+- Any Polymer parameters that are nullable or can be multiple types (other than
+  the one explicitly delared) will need type annotations.
 
 These tests require the `typescript` and `fried-twinkie` npm packages.
 
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 17c45a4..239ad0b 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -180,7 +180,7 @@
         "embed/test.html",
         "test/common-test-setup.html",
         ":embed_test_files",
-        ":polygerrit_embed_ui.zip",
+        ":pg_code.zip",
         ":test_components.zip",
     ],
     # Should not run sandboxed.
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
index fec459b..970bfc7 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>async-foreach-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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="async-foreach-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index c21e96f..b61b142 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>base-url-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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>
   /** @type {string} */
@@ -52,7 +54,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.BaseUrlBehavior,
         ],
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
index 96d4a08..2c513f3 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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"/>
 <title>docs-url-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="docs-url-behavior.html">
 
@@ -38,7 +40,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'docs-url-behavior-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.DocsUrlBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
index e445a78..8323ac6 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>dom-util-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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="dom-util-behavior.html">
 
@@ -46,7 +48,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.DomUtilBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
new file mode 100644
index 0000000..b5afab1
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
@@ -0,0 +1,55 @@
+<!--
+@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.
+-->
+
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.FireBehavior */
+  Gerrit.FireBehavior = {
+    /**
+     * Dispatches a custom event with an optional detail value.
+     *
+     * @param {string} type Name of event type.
+     * @param {*=} detail Detail value containing event-specific
+     *   payload.
+     * @param {{ bubbles: (boolean|undefined), cancelable: (boolean|undefined),
+     *     composed: (boolean|undefined) }=}
+     *  options Object specifying options.  These may include:
+     *  `bubbles` (boolean, defaults to `true`),
+     *  `cancelable` (boolean, defaults to false), and
+     *  `composed` (boolean, defaults to true).
+     * @return {!Event} The new event that was fired.
+     * @override
+     */
+    fire(type, detail, options) {
+      options = options || {};
+      detail = (detail === null || detail === undefined) ? {} : detail;
+      const event = new Event(type, {
+        bubbles: options.bubbles === undefined ? true : options.bubbles,
+        cancelable: Boolean(options.cancelable),
+        composed: options.composed === undefined ? true: options.composed,
+      });
+      event.detail = detail;
+      this.dispatchEvent(event);
+      return event;
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
index 530c76c..0c75c44 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
@@ -138,6 +138,7 @@
      *    object.
      */
     toSortedArray(obj) {
+      if (!obj) { return []; }
       return Object.keys(obj).map(key => {
         return {
           id: key,
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
index 0b37a0d..0d1ee57 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-access-behavior.html">
 
@@ -38,7 +40,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.AccessBehavior],
       });
     });
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 d217577..0285e35 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-admin-nav-behavior.html">
 
@@ -41,7 +43,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.AdminNavBehavior,
         ],
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index b052d06..791e2af 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-change-table-behavior.html">
 
@@ -48,7 +50,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.ChangeTableBehavior],
       });
     });
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 69%
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 820d6bc..3d4eca1 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,12 +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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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>
@@ -31,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 = {
@@ -44,9 +46,8 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element-anon',
-        _legacyUndefinedCheck: true,
         behaviors: [
-          Gerrit.AnonymousNameBehavior,
+          Gerrit.DisplayNameBehavior,
         ],
       });
     });
@@ -55,21 +56,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',
       };
@@ -77,11 +78,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',
@@ -89,5 +90,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/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
index f6c765f..535483d 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-list-view-behavior.html">
 
@@ -40,7 +42,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.ListViewBehavior],
       });
     });
@@ -68,10 +69,10 @@
 
     test('getFilterValue', () => {
       let params;
-      assert.equal(element.getFilterValue(params), null);
+      assert.equal(element.getFilterValue(params), '');
 
       params = {filter: null};
-      assert.equal(element.getFilterValue(params), null);
+      assert.equal(element.getFilterValue(params), '');
 
       params = {filter: 'test'};
       assert.equal(element.getFilterValue(params), 'test');
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index a4c1e86..28d6990 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -31,7 +31,7 @@
 
   window.Gerrit = window.Gerrit || {};
 
-  /** @polymerBehavior this */
+  /** @polymerBehavior Gerrit.PatchSetBehavior*/
   Gerrit.PatchSetBehavior = {
     EDIT_NAME: 'edit',
     PARENT_NAME: 'PARENT',
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index b858c51..3db4084 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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"/>
 <title>gr-patch-set-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-patch-set-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index 75c2433..0046290 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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"/>
 <title>gr-path-list-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-path-list-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
index 2dc070d..2fa9191 100644
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
@@ -20,7 +20,7 @@
 
   window.Gerrit = window.Gerrit || {};
 
-  /** @polymerBehavior this */
+  /** @polymerBehavior Gerrit.RepoPluginConfig*/
   Gerrit.RepoPluginConfig = {
     // Should be kept in sync with
     // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
index 07d3484..0e2e99f 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
 <script src="../../scripts/rootElement.js"></script>
 
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index 04d8b6e..0bf620f 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -89,7 +89,7 @@
       this._tooltip = tooltip;
       this.listen(window, 'scroll', '_handleWindowScroll');
       this.listen(this, 'mouseleave', '_handleHideTooltip');
-      this.listen(this, 'tap', '_handleHideTooltip');
+      this.listen(this, 'click', '_handleHideTooltip');
     },
 
     _handleHideTooltip(e) {
@@ -101,7 +101,7 @@
 
       this.unlisten(window, 'scroll', '_handleWindowScroll');
       this.unlisten(this, 'mouseleave', '_handleHideTooltip');
-      this.unlisten(this, 'tap', '_handleHideTooltip');
+      this.unlisten(this, 'click', '_handleHideTooltip');
       this.setAttribute('title', this._titleText);
       if (this._tooltip && this._tooltip.parentNode) {
         this._tooltip.parentNode.removeChild(this._tooltip);
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 943e000..173c8d4 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -17,9 +17,11 @@
 -->
 
 <title>tooltip-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-tooltip-behavior.html">
 
@@ -51,7 +53,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'tooltip-behavior-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.TooltipBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
index d909e86..73e51d3 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
@@ -17,9 +17,11 @@
 -->
 
 <title>gr-url-encoding-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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-url-encoding-behavior.html">
 
@@ -40,7 +42,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.URLEncodingBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index df7f3cf..3c5a733 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -96,8 +96,8 @@
 NOTE: doc-only shortcuts will not be customizable in the same way that other
 shortcuts are.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 
 <script>
 (function(window) {
@@ -180,6 +180,7 @@
 
     SEARCH: 'SEARCH',
     SEND_REPLY: 'SEND_REPLY',
+    EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
   };
 
   const _help = new Map();
@@ -292,6 +293,8 @@
       'Show/hide selected inline diff');
 
   _describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+  _describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
+      'Emoji dropdown');
 
   // Must be declared outside behavior implementation to be accessed inside
   // behavior functions.
@@ -423,6 +426,9 @@
     }
 
     describeBinding(binding) {
+      if (binding.length === 1) {
+        return [binding];
+      }
       return binding.split(':')[0].split('+').map(part => {
         switch (part) {
           case 'shift':
@@ -457,7 +463,7 @@
 
   window.Gerrit = window.Gerrit || {};
 
-  /** @polymerBehavior KeyboardShortcutBehavior */
+  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
   Gerrit.KeyboardShortcutBehavior = [
     Polymer.IronA11yKeysBehavior,
     {
@@ -506,7 +512,7 @@
       },
 
       // Alias for getKeyboardEvent.
-      /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
+      /** @return {!Event} */
       getKeyboardEvent(e) {
         return getKeyboardEvent(e);
       },
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index b4451fe..3183c7e 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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="keyboard-shortcut-behavior.html">
 
@@ -50,7 +52,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.KeyboardShortcutBehavior],
         keyBindings: {
           k: '_handleKey',
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index 354bedc..85bc6a1 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../base-url-behavior/base-url-behavior.html">
 <script>
 (function(window) {
@@ -97,6 +97,12 @@
 
       // Skip mergeability data.
       SKIP_MERGEABLE: 22,
+
+      /**
+       * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+       * deletions field (number of lines deleted)
+       */
+      SKIP_DIFFSTAT: 23,
     },
 
     listChangesOptionsToHex(...args) {
@@ -123,8 +129,8 @@
       return this.getBaseUrl() + '/c/' + changeNum;
     },
 
-    changeIsOpen(status) {
-      return status === this.ChangeStatus.NEW;
+    changeIsOpen(change) {
+      return change && change.status === this.ChangeStatus.NEW;
     },
 
     /**
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 6af43dc..a77a01f 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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>
   /** @type {string} */
@@ -54,7 +56,6 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
-        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.BaseUrlBehavior,
           Gerrit.RESTClientBehavior,
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
index 6e040a3..ab446f1 100644
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -17,9 +17,11 @@
 -->
 
 <title>safe-types-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.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.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="safe-types-behavior.html">
 
@@ -39,7 +41,6 @@
     suiteSetup(() => {
       Polymer({
         is: 'safe-types-element',
-        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.SafeTypes],
       });
     });
@@ -119,4 +120,4 @@
       });
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index 45bc5f6..ac65360 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -33,7 +34,7 @@
     <style include="shared-styles">
       :host {
         display: block;
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       fieldset {
         border: 1px solid var(--border-color);
@@ -50,13 +51,13 @@
         display: flex;
         justify-content: space-between;
         min-height: 3em;
-        padding: 0 .7em;
+        padding: 0 var(--spacing-m);
       }
       #deletedContainer {
         border-bottom: 0;
       }
       .sectionContent {
-        padding: .7em;
+        padding: var(--spacing-m);
       }
       #editBtn,
       .editing #editBtn.global,
@@ -65,11 +66,11 @@
       #addPermission,
       #deleteBtn,
       .editingRef .name,
-      #editRefInput {
+      .editRefInput {
         display: none;
       }
       .editing #editBtn,
-      .editingRef #editRefInput {
+      .editingRef .editRefInput {
         display: flex;
       }
       .deleted #deletedContainer {
@@ -82,7 +83,7 @@
       }
       .editing #deleteBtn,
       #undoRemoveBtn {
-        padding-right: .7em;
+        padding-right: var(--spacing-m);
       }
     </style>
     <style include="gr-form-styles"></style>
@@ -96,20 +97,26 @@
                 id="editBtn"
                 link
                 class$="[[_computeEditBtnClass(section.id)]]"
-                on-tap="editReference">
+                on-click="editReference">
               <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
             </gr-button>
           </div>
-          <input
-              id="editRefInput"
+          <iron-input
+              class="editRefInput"
               bind-value="{{section.id}}"
-              is="iron-input"
               type="text"
               on-input="_handleValueChange">
+            <input
+                class="editRefInput"
+                bind-value="{{section.id}}"
+                is="iron-input"
+                type="text"
+                on-input="_handleValueChange">
+          </iron-input>
           <gr-button
               link
               id="deleteBtn"
-              on-tap="_handleRemoveReference">Remove</gr-button>
+              on-click="_handleRemoveReference">Remove</gr-button>
         </div><!-- end header -->
         <div class="sectionContent">
           <template
@@ -140,7 +147,7 @@
             <gr-button
                 link
                 id="addBtn"
-                on-tap="_handleAddPermission">Add</gr-button>
+                on-click="_handleAddPermission">Add</gr-button>
           </div>
           <!-- end addPermission -->
         </div><!-- end sectionContent -->
@@ -150,7 +157,7 @@
         <gr-button
             link
             id="undoRemoveBtn"
-            on-tap="_handleUndoRemove">Undo</gr-button>
+            on-click="_handleUndoRemove">Undo</gr-button>
       </div><!-- end deletedContainer -->
     </fieldset>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index 71f8d26..77e35c6 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -39,7 +39,6 @@
 
   Polymer({
     is: 'gr-access-section',
-    _legacyUndefinedCheck: true,
 
     properties: {
       capabilities: Object,
@@ -72,6 +71,11 @@
 
     behaviors: [
       Gerrit.AccessBehavior,
+      /**
+       * Unused in this element, but called by other elements in tests
+       * e.g gr-repo-access_test.
+       */
+      Gerrit.FireBehavior,
     ],
 
     listeners: {
@@ -96,7 +100,8 @@
         // For a new section, this is not fired because new permissions and
         // rules have to be added in order to save, modifying the ref is not
         // enough.
-        this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'access-modified', {bubbles: true, composed: true}));
       }
       this.section.value.updatedId = this.section.id;
     },
@@ -123,6 +128,9 @@
 
     _computePermissions(name, capabilities, labels) {
       let allPermissions;
+      if (!this.section || !this.section.value) {
+        return [];
+      }
       if (name === GLOBAL_NAME) {
         allPermissions = this.toSortedArray(capabilities);
       } else {
@@ -147,6 +155,7 @@
 
     _computeLabelOptions(labels) {
       const labelOptions = [];
+      if (!labels) { return []; }
       for (const labelName of Object.keys(labels)) {
         labelOptions.push({
           id: 'label-' + labelName,
@@ -200,12 +209,13 @@
 
     _handleRemoveReference() {
       if (this.section.value.added) {
-        this.dispatchEvent(new CustomEvent('added-section-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-section-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.section.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
@@ -213,13 +223,19 @@
       delete this.section.value.deleted;
     },
 
+    editRefInput() {
+      return Polymer.dom(this.root).querySelector(Polymer.Element ?
+        'iron-input.editRefInput' :
+        'input[is=iron-input].editRefInput');
+    },
+
     editReference() {
       this._editingRef = true;
-      this.$.editRefInput.focus();
+      this.editRefInput().focus();
     },
 
     _isEditEnabled(canUpload, ownerOf, sectionId) {
-      return canUpload || ownerOf.indexOf(sectionId) >= 0;
+      return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
     },
 
     _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
index 56f321b..9c49270 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-access-section</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-access-section.html">
 
@@ -455,7 +457,7 @@
               1);
         });
 
-        test('edit section reference', () => {
+        test('edit section reference', done => {
           element.canUpload = true;
           element.ownerOf = [];
           element.section = {id: 'refs/for/bar', value: {permissions: {}}};
@@ -464,14 +466,16 @@
           assert.isTrue(element.$.section.classList.contains('editing'));
           assert.isFalse(element._editingRef);
           MockInteractions.tap(element.$.editBtn);
-          element.$.editRefInput.bindValue='new/ref';
-          flushAsynchronousOperations();
-          assert.equal(element.section.id, 'new/ref');
-          assert.isTrue(element._editingRef);
-          assert.isTrue(element.$.section.classList.contains('editingRef'));
-          element.editing = false;
-          assert.isFalse(element._editingRef);
-          assert.equal(element.section.id, 'refs/for/bar');
+          element.editRefInput().bindValue='new/ref';
+          setTimeout(() => {
+            assert.equal(element.section.id, 'new/ref');
+            assert.isTrue(element._editingRef);
+            assert.isTrue(element.$.section.classList.contains('editingRef'));
+            element.editing = false;
+            assert.isFalse(element._editingRef);
+            assert.equal(element.section.id, 'refs/for/bar');
+            done();
+          });
         });
 
         test('_handleValueChange', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index ea08d89..dd8758f 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -15,10 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -54,7 +54,7 @@
           <template is="dom-repeat" items="[[_shownGroups]]">
             <tr class="table">
               <td class="name">
-                <a href$="[[_computeGroupUrl(item.group_id)]]">[[item.name]]</a>
+                <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
               </td>
               <td class="description">[[item.description]]</td>
               <td class="visibleToAll">[[_visibleToAll(item)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 9db2e34..cf2100d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-admin-group-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -68,6 +67,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.ListViewBehavior,
     ],
 
@@ -97,8 +97,13 @@
       }
     },
 
+    /**
+     * Generates groups link (/admin/groups/<uuid>)
+     *
+     * @param {string} id
+     */
     _computeGroupUrl(id) {
-      return Gerrit.Nav.getUrlForGroup(id);
+      return Gerrit.Nav.getUrlForGroup(decodeURIComponent(id));
     },
 
     _getCreateGroupCapability() {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index 065a757..bd9b30a 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-group-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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"/>
 
@@ -67,6 +69,30 @@
       sandbox.restore();
     });
 
+    test('_computeGroupUrl', () => {
+      let urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+          () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+      let group = {
+        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+      };
+      assert.equal(element._computeGroupUrl(group),
+          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+      urlStub.restore();
+
+      urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+          () => '/admin/groups/user/test');
+
+      group = {
+        id: 'user%2Ftest',
+      };
+      assert.equal(element._computeGroupUrl(group),
+          '/admin/groups/user/test');
+
+      urlStub.restore();
+    });
+
     suite('list with groups', () => {
       setup(done => {
         groups = _.times(26, groupGenerator);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index b6a6d27..c7187a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
@@ -56,7 +56,7 @@
         padding: 5px 4px;
       }
       iron-icon {
-        margin: 0 .2em;
+        margin: 0 var(--spacing-xs);
       }
       .breadcrumb {
         align-items: center;
@@ -74,7 +74,7 @@
         display: inline-block;
       }
       main.breadcrumbs:not(.table) {
-        margin-top: 1em;
+        margin-top: var(--spacing-l);
       }
     </style>
     <gr-page-nav class="navStyles">
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 4d964d7..72cca9e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-admin-view',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
@@ -198,10 +197,6 @@
       this.reload();
     },
 
-    _computeSelectedTitle(params) {
-      return this.getSelectedTitle(params.view);
-    },
-
     // TODO (beckysiegel): Update these functions after router abstraction is
     // updated. They are currently copied from gr-dropdown (and should be
     // updated there as well once complete).
@@ -228,6 +223,7 @@
      * @param {string=} opt_detailType
      */
     _computeSelectedClass(itemView, params, opt_detailType) {
+      if (!params) return '';
       // Group params are structured differently from admin params. Compute
       // selected differently for groups.
       // TODO(wyatta): Simplify this when all routes work like group params.
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 ff25377..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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-admin-view.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
index 3873083..9d8ee18 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index 9c0e405..acc76de 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -25,7 +25,6 @@
 
   Polymer({
     is: 'gr-confirm-delete-item-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -44,6 +43,10 @@
       itemType: String,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
index d735acb..3292cec 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-delete-item-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-delete-item-dialog.html">
 
@@ -49,19 +51,23 @@
     test('_handleConfirmTap', () => {
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
-      sandbox.stub(element, '_handleConfirmTap');
+      sandbox.spy(element, '_handleConfirmTap');
       element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
+      assert.isTrue(confirmHandler.calledOnce);
       assert.isTrue(element._handleConfirmTap.called);
+      assert.isTrue(element._handleConfirmTap.calledOnce);
     });
 
     test('_handleCancelTap', () => {
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
-      sandbox.stub(element, '_handleCancelTap');
+      sandbox.spy(element, '_handleCancelTap');
       element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
+      assert.isTrue(cancelHandler.calledOnce);
       assert.isTrue(element._handleCancelTap.called);
+      assert.isTrue(element._handleCancelTap.calledOnce);
     });
 
     test('_computeItemName function for branches', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index da1c871..2a95991 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -42,7 +43,7 @@
       }
       gr-autocomplete {
         --gr-autocomplete: {
-          padding: 0 .15em;
+          padding: 0 var(--spacing-xs);
         }
       }
       .hide {
@@ -69,23 +70,33 @@
       <section class$="[[_computeBranchClass(baseChange)]]">
         <span class="title">Provide base commit sha1 for change</span>
         <span class="value">
-          <input
-              is="iron-input"
-              id="baseCommitInput"
+          <iron-input
               maxlength="40"
               placeholder="(optional)"
               bind-value="{{baseCommit}}">
+            <input
+                is="iron-input"
+                id="baseCommitInput"
+                maxlength="40"
+                placeholder="(optional)"
+                bind-value="{{baseCommit}}">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Enter topic for new change</span>
         <span class="value">
-          <input
-              is="iron-input"
-              id="tagNameInput"
+          <iron-input
               maxlength="1024"
               placeholder="(optional)"
               bind-value="{{topic}}">
+            <input
+                is="iron-input"
+                id="tagNameInput"
+                maxlength="1024"
+                placeholder="(optional)"
+                bind-value="{{topic}}">
+          </iron-input>
         </span>
       </section>
       <section id="description">
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 5a4c064..e29e5f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-create-change-dialog',
-    _legacyUndefinedCheck: true,
 
     properties: {
       repoName: String,
@@ -50,6 +49,11 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      /**
+       * Unused in this element, but called by other elements in tests
+       * e.g gr-repo-commands_test.
+       */
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index aa4da68..3a3683f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-create-change-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
index d96a935..8a4287b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -39,10 +39,12 @@
       <div id="form">
         <section>
           <span class="title">Group name</span>
-          <input
-              is="iron-input"
-              id="groupNameInput"
+          <iron-input
               bind-value="{{_name}}">
+            <input
+                is="iron-input"
+                bind-value="{{_name}}">
+          </iron-input>
         </section>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index ec667ee..01aeb43 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-create-group-dialog',
-    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
index 95ffdb1..ebca289 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-group-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-create-group-dialog.html">
 
@@ -51,13 +53,16 @@
       sandbox.restore();
     });
 
-    test('name is updated correctly', () => {
+    test('name is updated correctly', done => {
       assert.isFalse(element.hasNewGroupName);
 
-      element.$.groupNameInput.bindValue = GROUP_NAME;
+      ironInput(element.root).bindValue = GROUP_NAME;
 
-      assert.isTrue(element.hasNewGroupName);
-      assert.deepEqual(element._name, GROUP_NAME);
+      setTimeout(() => {
+        assert.isTrue(element.hasNewGroupName);
+        assert.deepEqual(element._name, GROUP_NAME);
+        done();
+      });
     });
 
     test('test for redirecting to group on successful creation', done => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
index aa9639b..33153eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -45,29 +45,39 @@
     </style>
     <div class="gr-form-styles">
       <div id="form">
-        <section>
+        <section id="itemNameSection">
           <span class="title">[[detailType]] name</span>
-          <input
-              is="iron-input"
-              id="itemNameInput"
+          <iron-input
               placeholder="[[detailType]] Name"
               bind-value="{{_itemName}}">
+            <input
+                is="iron-input"
+                placeholder="[[detailType]] Name"
+                bind-value="{{_itemName}}">
+          </iron-input>
         </section>
-        <section>
+        <section id="itemRevisionSection">
           <span class="title">Initial Revision</span>
-          <input
-              is="iron-input"
-              id="itemRevisionInput"
+          <iron-input
               placeholder="Revision (Branch or SHA-1)"
               bind-value="{{_itemRevision}}">
+            <input
+                is="iron-input"
+                placeholder="Revision (Branch or SHA-1)"
+                bind-value="{{_itemRevision}}">
+          </iron-input>
         </section>
-        <section class$="[[_computeHideItemClass(itemDetail)]]">
+        <section id="itemAnnotationSection"
+                 class$="[[_computeHideItemClass(itemDetail)]]">
           <span class="title">Annotation</span>
-          <input
-              is="iron-input"
-              id="itemAnnotationInput"
+          <iron-input
               placeholder="Annotation (Optional)"
               bind-value="{{_itemAnnotation}}">
+            <input
+                is="iron-input"
+                placeholder="Annotation (Optional)"
+                bind-value="{{_itemAnnotation}}">
+          </iron-input>
         </section>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 65bb46d..4e9da90 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -24,7 +24,6 @@
 
   Polymer({
     is: 'gr-create-pointer-dialog',
-    _legacyUndefinedCheck: true,
 
     properties: {
       detailType: String,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
index 39e200a..08e8213 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-pointer-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-create-pointer-dialog.html">
 
@@ -49,7 +51,7 @@
       sandbox.restore();
     });
 
-    test('branch created', () => {
+    test('branch created', done => {
       sandbox.stub(element.$.restAPI, 'createRepoBranch', () => {
         return Promise.resolve({});
       });
@@ -59,17 +61,18 @@
       element._itemName = 'test-branch';
       element.itemDetail = 'branches';
 
-      element.$.itemNameInput.bindValue = 'test-branch2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-branch2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-branch2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
-    test('tag created', () => {
+    test('tag created', done => {
       sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
@@ -79,17 +82,18 @@
       element._itemName = 'test-tag';
       element.itemDetail = 'tags';
 
-      element.$.itemNameInput.bindValue = 'test-tag2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-tag2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-tag2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
-    test('tag created with annotations', () => {
+    test('tag created with annotations', done => {
       sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
@@ -100,17 +104,17 @@
       element._itemAnnotation = 'test-message';
       element.itemDetail = 'tags';
 
-      element.$.itemNameInput.bindValue = 'test-tag2';
-      element.$.itemAnnotationInput.bindValue = 'test-message2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+      ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-tag2');
-
-      assert.equal(element._itemAnnotation, 'test-message2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-tag2');
+        assert.equal(element._itemAnnotation, 'test-message2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
     test('_computeHideItemClass returns hideItem if type is branches', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index b38fab5..d1a2471 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
@@ -41,10 +41,9 @@
         border: none;
         --gr-autocomplete: {
           border: 1px solid var(--border-color);
-          border-radius: 2px;
-          font-size: var(--font-size-normal);
+          border-radius: var(--border-radius);
           height: 2em;
-          padding: 0 .15em;
+          padding: 0 var(--spacing-xs);
           width: 20em;
         }
       }
@@ -54,10 +53,13 @@
       <div id="form">
         <section>
           <span class="title">Repository name</span>
-          <input is="iron-input"
-              id="repoNameInput"
-              autocomplete="on"
-              bind-value="{{_repoConfig.name}}">
+          <iron-input autocomplete="on"
+                      bind-value="{{_repoConfig.name}}">
+            <input is="iron-input"
+                   id="repoNameInput"
+                   autocomplete="on"
+                   bind-value="{{_repoConfig.name}}">
+          </iron-input>
         </section>
         <section>
           <span class="title">Rights inherit from</span>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index ef7edd4..bb2b5f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-create-repo-dialog',
-    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
index 79079f5..7e32c5c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-repo-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-create-repo-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
index b10c98a..c15f091 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
@@ -16,12 +16,14 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.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/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 
 <dom-module id="gr-group-audit-log">
   <template>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index 966f3c9..8901d4a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-group-audit-log',
-    _legacyUndefinedCheck: true,
 
     properties: {
       groupId: String,
@@ -33,6 +32,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.ListViewBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
index 59a665b..313d465 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-audit-log</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-group-audit-log.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index bcfc9e1..86f66c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -16,10 +16,10 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
@@ -43,7 +43,6 @@
       gr-autocomplete {
         width: 20em;
         --gr-autocomplete: {
-          font-size: var(--font-size-normal);
           height: 2em;
           width: 20em;
         }
@@ -91,7 +90,7 @@
             </span>
             <gr-button
                 id="saveGroupMember"
-                on-tap="_handleSavingGroupMember"
+                on-click="_handleSavingGroupMember"
                 disabled="[[!_groupMemberSearchId]]">
               Add
             </gr-button>
@@ -111,7 +110,7 @@
                     <td class="deleteColumn">
                       <gr-button
                           class="deleteMembersButton"
-                          on-tap="_handleDeleteMember">
+                          on-click="_handleDeleteMember">
                         Delete
                       </gr-button>
                     </td>
@@ -133,7 +132,7 @@
             </span>
             <gr-button
                 id="saveIncludedGroups"
-                on-tap="_handleSavingIncludedGroups"
+                on-click="_handleSavingIncludedGroups"
                 disabled="[[!_includedGroupSearchId]]">
               Add
             </gr-button>
@@ -163,7 +162,7 @@
                     <td class="deleteColumn">
                       <gr-button
                           class="deleteIncludedGroupButton"
-                          on-tap="_handleDeleteIncludedGroup">
+                          on-click="_handleDeleteIncludedGroup">
                         Delete
                       </gr-button>
                     </td>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index b2784e5..7f8e9ac 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -25,7 +25,6 @@
 
   Polymer({
     is: 'gr-group-members',
-    _legacyUndefinedCheck: true,
 
     properties: {
       groupId: Number,
@@ -66,6 +65,7 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
@@ -205,6 +205,7 @@
               this.dispatchEvent(new CustomEvent('show-alert', {
                 detail: {message: SAVING_ERROR_TEXT},
                 bubbles: true,
+                composed: true,
               }));
               return err;
             }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index 45d5497..bf9113b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-members</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-group-members.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
index b92dc4b..7617c19 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -35,7 +35,7 @@
         content: ' *';
       }
       .inputUpdateBtn {
-        margin-top: .3em;
+        margin-top: var(--spacing-s);
       }
     </style>
     <style include="gr-form-styles"></style>
@@ -51,7 +51,8 @@
             <h3 id="groupUUID">Group UUID</h3>
             <fieldset>
               <gr-copy-clipboard
-                  text="[[_groupConfig.id]]"></gr-copy-clipboard>
+                  id="uuid"
+                  text="[[_getGroupUUID(_groupConfig.id)]]"></gr-copy-clipboard>
             </fieldset>
             <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
               Group Name
@@ -66,7 +67,7 @@
               <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 <gr-button
                     id="inputUpdateNameBtn"
-                    on-tap="_handleSaveName"
+                    on-click="_handleSaveName"
                     disabled="[[!_rename]]">
                   Rename Group</gr-button>
               </span>
@@ -86,7 +87,7 @@
               </span>
               <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 <gr-button
-                    on-tap="_handleSaveOwner"
+                    on-click="_handleSaveOwner"
                     disabled="[[!_owner]]">
                   Change Owners</gr-button>
               </span>
@@ -104,7 +105,7 @@
               </div>
               <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 <gr-button
-                    on-tap="_handleSaveDescription"
+                    on-click="_handleSaveDescription"
                     disabled="[[!_description]]">
                   Save Description
                 </gr-button>
@@ -132,7 +133,7 @@
               </section>
               <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 <gr-button
-                    on-tap="_handleSaveOptions"
+                    on-click="_handleSaveOptions"
                     disabled="[[!_options]]">
                   Save Group Options
                 </gr-button>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
index 2d7f9cb..09953fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -32,7 +32,6 @@
 
   Polymer({
     is: 'gr-group',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the group name changes.
@@ -89,6 +88,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_handleConfigName(_groupConfig.name)',
       '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
@@ -233,5 +236,11 @@
     _computeGroupDisabled(owner, admin, groupIsInternal) {
       return groupIsInternal && (admin || owner) ? false : true;
     },
+
+    _getGroupUUID(id) {
+      if (!id) return;
+
+      return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
index 226f3ab..fc04c02 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-group.html">
 
@@ -240,5 +242,19 @@
 
       element._loadGroup();
     });
+
+    test('uuid', () => {
+      element._groupConfig = {
+        id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+      };
+
+      assert.equal(element._groupConfig.id, element.$.uuid.text);
+
+      element._groupConfig = {
+        id: 'user%2Fgroup',
+      };
+
+      assert.equal('user/group', element.$.uuid.text);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index a5bb5fd..931e2cd 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -15,9 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -31,13 +32,13 @@
     <style include="shared-styles">
       :host {
         display: block;
-        margin-bottom: .7em;
+        margin-bottom: var(--spacing-m);
       }
       .header {
         align-items: baseline;
         display: flex;
         justify-content: space-between;
-        margin: .3em .7em;
+        margin: var(--spacing-s) var(--spacing-m);
       }
       .rules {
         background: var(--table-header-background-color);
@@ -48,7 +49,7 @@
         border-bottom: 1px solid var(--border-color);
       }
       .title {
-        margin-bottom: .3em;
+        margin-bottom: var(--spacing-s);
       }
       #addRule,
       #removeBtn {
@@ -60,11 +61,11 @@
       }
       .editing #removeBtn {
         display: block;
-        margin-left: 1.5em;
+        margin-left: var(--spacing-xl);
       }
       .editing #addRule {
         display: block;
-        padding: .7em;
+        padding: var(--spacing-m);
       }
       #deletedContainer,
       .deleted #mainContainer {
@@ -75,7 +76,7 @@
         border: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
-        padding: .7em;
+        padding: var(--spacing-m);
       }
       #mainContainer {
         display: block;
@@ -100,7 +101,7 @@
             <gr-button
                 link
                 id="removeBtn"
-                on-tap="_handleRemovePermission">Remove</gr-button>
+                on-click="_handleRemovePermission">Remove</gr-button>
           </div>
         </div><!-- end header -->
         <div class="rules">
@@ -136,7 +137,7 @@
         <gr-button
             link
             id="undoRemoveBtn"
-            on-tap="_handleUndoRemove">Undo</gr-button>
+            on-click="_handleUndoRemove">Undo</gr-button>
       </div><!-- end deletedContainer -->
     </section>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 58a006d..75e715b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -38,7 +38,6 @@
 
   Polymer({
     is: 'gr-permission',
-    _legacyUndefinedCheck: true,
 
     properties: {
       labels: Object,
@@ -78,6 +77,11 @@
 
     behaviors: [
       Gerrit.AccessBehavior,
+      /**
+       * Unused in this element, but called by other elements in tests
+       * e.g gr-access-section_test.
+       */
+      Gerrit.FireBehavior,
     ],
 
     observers: [
@@ -138,17 +142,19 @@
     _handleValueChange() {
       this.permission.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRemovePermission() {
       if (this.permission.value.added) {
-        this.dispatchEvent(new CustomEvent('added-permission-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-permission-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.permission.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRulesChanged(changeRecord) {
@@ -177,7 +183,8 @@
     },
 
     _computeLabel(permission, labels) {
-      if (!permission.value.label) { return; }
+      if (!labels || !permission ||
+          !permission.value || !permission.value.label) { return; }
 
       const labelName = permission.value.label;
 
@@ -281,7 +288,8 @@
       value.added = true;
       // See comment above for why we cannot use "this.set(...)" here.
       this.permission.value.rules[groupId] = value;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _computeHasRange(name) {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index a432ab0..0c70f9c 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-permission</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-permission.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
index ca98c50..2761526 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
@@ -15,10 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -33,22 +33,22 @@
       .existingItems {
         background: var(--table-header-background-color);
         border: 1px solid var(--border-color);
-        border-radius: 2px;
+        border-radius: var(--border-radius);
       }
       gr-button {
         float: right;
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
         width: 4.5em;
       }
       .row {
         align-items: center;
         display: flex;
         justify-content: space-between;
-        padding: .5em 0;
+        padding: var(--spacing-m) 0;
         width: 100%;
       }
       .existingItems .row {
-        padding: .5em;
+        padding: var(--spacing-m);
       }
       .existingItems .row:not(:first-of-type) {
         border-top: 1px solid var(--border-color);
@@ -61,7 +61,7 @@
       }
       .placeholder {
         color: var(--deemphasized-text-color);
-        padding-top: .75em;
+        padding-top: var(--spacing-m);
       }
     </style>
     <div class="wrapper gr-form-styles">
@@ -73,8 +73,8 @@
               <gr-button
                   link
                   disabled$="[[disabled]]"
-                  data-item="[[item]]"
-                  on-tap="_handleDelete">Delete</gr-button>
+                  data-item$="[[item]]"
+                  on-click="_handleDelete">Delete</gr-button>
             </div>
           </template>
         </div>
@@ -83,16 +83,20 @@
         <div class="row placeholder">None configured.</div>
       </template>
       <div class$="row [[_computeShowInputRow(disabled)]]">
-        <input
-            is="iron-input"
-            id="input"
+        <iron-input
             on-keydown="_handleInputKeydown"
-            bind-value="{{_newValue}}"/>
+            bind-value="{{_newValue}}">
+          <input
+              is="iron-input"
+              id="input"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newValue}}">
+        </iron-input>
         <gr-button
             id="addButton"
             disabled$="[[!_newValue.length]]"
             link
-            on-tap="_handleAddTap">Add</gr-button>
+            on-click="_handleAddTap">Add</gr-button>
       </div>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
index ab4d286..bb0d501 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -67,7 +67,7 @@
     },
 
     _handleDelete(e) {
-      const value = Polymer.dom(e).localTarget.dataItem;
+      const value = Polymer.dom(e).localTarget.dataset.item;
       this._dispatchChanged(
           this.pluginOption.info.values.filter(str => str !== value));
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
index dc3f67e..39e4ddc 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-config-array-editor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-plugin-config-array-editor.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
index 9e4396a..6ef84bf 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -14,8 +14,9 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
index d6484d8..1dbfdc8 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-plugin-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -65,6 +64,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.ListViewBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 9781cf7..96fff60 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-plugin-list.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
index b6e56de..ea12908 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
@@ -48,20 +49,20 @@
         align-items: center;
       }
       .weblink {
-        margin-right: .2em;
+        margin-right: var(--spacing-xs);
       }
       .weblinks.show,
       .referenceContainer {
         display: block;
       }
       .rightsText {
-        margin-right: .3rem;
+        margin-right: var(--spacing-s);
       }
 
       .editing gr-button,
       .admin #editBtn {
         display: inline-block;
-        margin: 1em 0;
+        margin: var(--spacing-l) 0;
       }
       .editing #editInheritFromInput {
         display: inline-block;
@@ -95,16 +96,16 @@
           </template>
         </div>
         <gr-button id="editBtn"
-            on-tap="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
+            on-click="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
         <gr-button id="saveBtn"
             primary
             class$="[[_computeSaveBtnClass(_ownerOf)]]"
-            on-tap="_handleSave"
+            on-click="_handleSave"
             disabled$="[[!_modified]]">Save</gr-button>
         <gr-button id="saveReviewBtn"
             primary
             class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-            on-tap="_handleSaveForReview"
+            on-click="_handleSaveForReview"
             disabled$="[[!_modified]]">Save for review</gr-button>
         <template
             is="dom-repeat"
@@ -124,7 +125,7 @@
         </template>
         <div class="referenceContainer">
           <gr-button id="addReferenceBtn"
-              on-tap="_handleCreateSection">Add Reference</gr-button>
+              on-click="_handleCreateSection">Add Reference</gr-button>
         </div>
       </div>
     </main>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 1b8b322..1e6a431 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -70,7 +70,6 @@
 
   Polymer({
     is: 'gr-repo-access',
-    _legacyUndefinedCheck: true,
 
     properties: {
       repo: {
@@ -118,6 +117,7 @@
     behaviors: [
       Gerrit.AccessBehavior,
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
@@ -237,7 +237,7 @@
     },
 
     _computeWebLinkClass(weblinks) {
-      return weblinks.length ? 'show' : '';
+      return weblinks && weblinks.length ? 'show' : '';
     },
 
     _computeShowInherit(inheritsFrom) {
@@ -413,8 +413,11 @@
       if (!Object.keys(addRemoveObj.add).length &&
           !Object.keys(addRemoveObj.remove).length &&
           !addRemoveObj.parent) {
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: NOTHING_TO_SAVE}, bubbles: true}));
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: NOTHING_TO_SAVE},
+          bubbles: true,
+          composed: true,
+        }));
         return;
       }
       const obj = {
@@ -450,12 +453,12 @@
     },
 
     _computeSaveBtnClass(ownerOf) {
-      return ownerOf.length < 0 ? 'invisible' : '';
+      return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
     },
 
     _computeMainClass(ownerOf, canUpload, editing) {
       const classList = [];
-      if (ownerOf.length > 0 || canUpload) {
+      if (ownerOf && ownerOf.length > 0 || canUpload) {
         classList.push('admin');
       }
       if (editing) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index a33b4be..1660088 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-access</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-repo-access.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
index 7db4e4c..29bc02d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 
@@ -23,14 +23,15 @@
     <style include="shared-styles">
       :host {
         display: block;
-        margin-bottom: 2em;
+        margin-bottom: var(--spacing-xxl);
       }
     </style>
     <h3>[[title]]</h3>
     <gr-button
         title$="[[tooltip]]"
         disabled$="[[disabled]]"
-        on-tap="_onCommandTap">
+        on-click
+        ="_onCommandTap">
       [[title]]
     </gr-button>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index e0becaf..026c990 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-repo-command',
-    _legacyUndefinedCheck: true,
 
     properties: {
       title: String,
@@ -34,7 +33,8 @@
      */
 
     _onCommandTap() {
-      this.dispatchEvent(new CustomEvent('command-tap', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('command-tap', {bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
index 9f9ac92..49d8765 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-command</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo-command.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
index dba01aa..5089f34 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 09c9026..3b4811e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -28,7 +28,6 @@
 
   Polymer({
     is: 'gr-repo-commands',
-    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
@@ -42,6 +41,10 @@
       _canCreate: Boolean,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     attached() {
       this._loadRepo();
 
@@ -75,8 +78,9 @@
     _handleRunningGC() {
       return this.$.restAPI.runRepoGC(this.repo).then(response => {
         if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent(
+              'show-alert',
+              {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
         }
       });
     },
@@ -101,7 +105,7 @@
           CREATE_CHANGE_SUCCEEDED_MESSAGE :
           CREATE_CHANGE_FAILED_MESSAGE;
         this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true}));
+            {detail: {message}, bubbles: true, composed: true}));
         if (!change) { return; }
 
         Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
index 76c65e8..2976923 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-commands</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-repo-commands.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index 1d49db9..8af3a92 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -14,7 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.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="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -24,7 +25,7 @@
     <style include="shared-styles">
       :host {
         display: block;
-        margin-bottom: 2em;
+        margin-bottom: var(--spacing-xxl);
       }
       .loading #dashboards,
       #loadingContainer {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index f72e986..71cc571 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-repo-dashboards',
-    _legacyUndefinedCheck: true,
 
     properties: {
       repo: {
@@ -33,6 +32,10 @@
       _dashboards: Array,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     _repoChanged(repo) {
       this._loading = true;
       if (!repo) { return Promise.resolve(); }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 94bf5e0..4f76983 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-dashboards</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo-dashboards.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
index fccfa6a..2f244f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -17,8 +17,9 @@
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.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/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -64,15 +65,14 @@
         display: none;
       }
       .revisionEdit gr-button {
-        margin-left: .6em;
+        margin-left: var(--spacing-m);
       }
       .editBtn {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       .canEdit .revisionEdit{
         align-items: center;
         display: flex;
-        line-height: 1;
       }
       .deleteButton:not(.show) {
         display: none;
@@ -120,23 +120,26 @@
                   </span>
                   <gr-button
                       link
-                      on-tap="_handleEditRevision"
+                      on-click="_handleEditRevision"
                       class="editBtn">
                     edit
                   </gr-button>
-                  <input
-                      is=iron-input
+                  <iron-input
                       bind-value="{{_revisedRef}}"
                       class="editItem">
+                    <input
+                        is="iron-input"
+                        bind-value="{{_revisedRef}}">
+                  </iron-input>
                   <gr-button
                       link
-                      on-tap="_handleCancelRevision"
+                      on-click="_handleCancelRevision"
                       class="cancelBtn editItem">
                     Cancel
                   </gr-button>
                   <gr-button
                       link
-                      on-tap="_handleSaveRevision"
+                      on-click="_handleSaveRevision"
                       class="saveBtn editItem"
                       disabled="[[!_revisedRef]]">
                     Save
@@ -172,7 +175,7 @@
                 <gr-button
                     link
                     class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                    on-tap="_handleDeleteItem">
+                    on-click="_handleDeleteItem">
                   Delete
                 </gr-button>
               </td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
index e4000e0..9052322 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -26,7 +26,6 @@
 
   Polymer({
     is: 'gr-repo-detail-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -85,6 +84,7 @@
 
     behaviors: [
       Gerrit.ListViewBehavior,
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
index 427a78a..44d9b27 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-detail-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-repo-detail-list.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
index 0490db2..5e82c1e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -14,10 +14,9 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
@@ -30,6 +29,20 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-table-styles"></style>
+    <style>
+      .genericList tr td:last-of-type {
+        text-align: left;
+      }
+      .genericList tr th:last-of-type {
+        text-align: left;
+      }
+      .readOnly {
+        text-align: center;
+      }
+      .changesLink, .name, .repositoryBrowser, .readOnly {
+        white-space:nowrap;
+      }
+    </style>
     <gr-list-view
         create-new=[[_createNewCapability]]
         filter="[[_filter]]"
@@ -42,10 +55,10 @@
       <table id="list" class="genericList">
         <tr class="headerRow">
           <th class="name topHeader">Repository Name</th>
-          <th class="description topHeader">Repository Description</th>
-          <th class="changesLink topHeader">Changes</th>
           <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="readOnly topHeader">Read only</th>
+          <th class="changesLink topHeader">Changes</th>
+          <th class="topHeader readOnly">Read only</th>
+          <th class="description topHeader">Repository Description</th>
         </tr>
         <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
@@ -56,8 +69,6 @@
               <td class="name">
                 <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
               </td>
-              <td class="description">[[item.description]]</td>
-              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">(view all)</a></td>
               <td class="repositoryBrowser">
                 <template is="dom-repeat"
                     items="[[_computeWeblink(item)]]" as="link">
@@ -65,11 +76,13 @@
                       class="webLink"
                       rel="noopener"
                       target="_blank">
-                    ([[link.name]])
+                    [[link.name]]
                   </a>
                 </template>
               </td>
+              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">view all</a></td>
               <td class="readOnly">[[_readOnly(item)]]</td>
+              <td class="description">[[item.description]]</td>
             </tr>
           </template>
         </tbody>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index d4eb29b..0eaa496 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-repo-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
index 4bc023f..c77592c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-repo-list.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
index 7f2cbe7..d2093e4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 
 <link rel="import" href="../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
@@ -35,14 +35,14 @@
     <style include="gr-subpage-styles">
       .inherited {
         color: var(--deemphasized-text-color);
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       section.section:not(.ARRAY) .title {
         align-items: center;
         display: flex;
       }
       section.section.ARRAY .title {
-        padding-top: .75em;
+        padding-top: var(--spacing-m);
       }
     </style>
     <div class="gr-form-styles">
@@ -87,12 +87,18 @@
                 </gr-select>
               </template>
               <template is="dom-if" if="[[_isString(option.info.type)]]">
-                <input
-                    is="iron-input"
-                    value="[[option.info.value]]"
+                <iron-input
+                    bind-value="[[option.info.value]]"
                     on-input="_handleStringChange"
                     data-option-key$="[[option._key]]"
-                    disabled$="[[_computeDisabled(option.info.editable)]]"></input>
+                    disabled$="[[_computeDisabled(option.info.editable)]]">
+                  <input
+                      is="iron-input"
+                      value="[[option.info.value]]"
+                      on-input="_handleStringChange"
+                      data-option-key$="[[option._key]]"
+                      disabled$="[[_computeDisabled(option.info.editable)]]">
+                </iron-input>
               </template>
               <template is="dom-if" if="[[option.info.inherited_value]]">
                 <span class="inherited">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index 6d7677e..bfa8832 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -71,7 +71,10 @@
       return editable === 'false';
     },
 
-    _computeChecked(value) {
+    /**
+     * @param {string} value - fallback to 'false' if undefined
+     */
+    _computeChecked(value = 'false') {
       return JSON.parse(value);
     },
 
@@ -123,8 +126,8 @@
         notifyPath: `${name}.${notifyPath}`,
       };
 
-      this.dispatchEvent(new CustomEvent(this.PLUGIN_CONFIG_CHANGED,
-          {detail, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
index ba0c876..0a6846f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-plugin-config</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo-plugin-config.html">
 
@@ -151,7 +153,8 @@
         const select = element.$$('select');
         assert.ok(select);
         select.value = 'newTest';
-        select.dispatchEvent(new Event('change', {bubbles: true}));
+        select.dispatchEvent(new Event(
+            'change', {bubbles: true, composed: true}));
         flushAsynchronousOperations();
 
         assert.isTrue(buildStub.called);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index 8604ad8..5de77b9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -15,11 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -270,12 +272,18 @@
               <section>
                 <span class="title">Maximum Git object size limit</span>
                 <span class="value">
-                  <input
-                      id="maxGitObjSizeInput"
+                  <iron-input
+                      id="maxGitObjSizeIronInput"
                       bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                      is="iron-input"
                       type="text"
                       disabled$="[[_readOnly]]">
+                    <input
+                        id="maxGitObjSizeInput"
+                        bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                        is="iron-input"
+                        type="text"
+                        disabled$="[[_readOnly]]">
+                  </iron-input>
                   <template is="dom-if" if="[[_repoConfig.max_object_size_limit.value]]">
                     effective: [[_repoConfig.max_object_size_limit.value]] bytes
                   </template>
@@ -356,7 +364,7 @@
               </template>
             </div>
             <gr-button
-                on-tap="_handleSaveRepoConfig"
+                on-click="_handleSaveRepoConfig"
                 disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
           </fieldset>
           <gr-endpoint-decorator name="repo-config">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index ff630c4..153a137 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -53,7 +53,6 @@
 
   Polymer({
     is: 'gr-repo',
-    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
@@ -109,6 +108,10 @@
       _schemesObj: Object,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_handleConfigChanged(_repoConfig.*)',
     ],
@@ -309,6 +312,9 @@
     },
 
     _computeCommands(repo, schemesObj, _selectedScheme) {
+      if (!schemesObj || !repo || !_selectedScheme) {
+        return [];
+      }
       const commands = [];
       let commandObj;
       if (schemesObj.hasOwnProperty(_selectedScheme)) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
index 60645bb..4e81565 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo.html">
 
@@ -100,10 +102,12 @@
     const SCHEMES = {http: {}, repo: {}, ssh: {}};
 
     function getFormFields() {
-      const selects = Polymer.dom(element.root).querySelectorAll('select');
-      const textareas =
-          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea');
-      const inputs = Polymer.dom(element.root).querySelectorAll('input');
+      const selects = Array.from(
+          Polymer.dom(element.root).querySelectorAll('select'));
+      const textareas = Array.from(
+          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea'));
+      const inputs = Array.from(
+          Polymer.dom(element.root).querySelectorAll('input'));
       return inputs.concat(textareas).concat(selects);
     }
 
@@ -362,8 +366,9 @@
               configInputObj.private_by_default;
           element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
               configInputObj.match_author_to_committer_date;
-          element.$.maxGitObjSizeInput.bindValue =
-              configInputObj.max_object_size_limit;
+          const inputElement = Polymer.Element ?
+            element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+          inputElement.bindValue = configInputObj.max_object_size_limit;
           element.$.contributorAgreementSelect.bindValue =
               configInputObj.use_contributor_agreements;
           element.$.useSignedOffBySelect.bindValue =
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
index c8ae650..9820e31 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -15,22 +15,25 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-rule-editor">
   <template>
     <style include="shared-styles">
       :host {
         border-bottom: 1px solid var(--border-color);
-        padding: .7em;
+        padding: var(--spacing-m);
         display: block;
       }
       #removeBtn {
@@ -44,7 +47,7 @@
         display: flex;
       }
       #options > * {
-        margin-right: .5em;
+        margin-right: var(--spacing-m);
       }
       #mainContainer {
         align-items: baseline;
@@ -146,14 +149,14 @@
       <gr-button
           link
           id="removeBtn"
-          on-tap="_handleRemoveRule">Remove</gr-button>
+          on-click="_handleRemoveRule">Remove</gr-button>
     </div>
     <div
         id="deletedContainer"
         class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       [[groupName]] was deleted
       <gr-button link
-          id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
+          id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 84c6101..8d94a90 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -66,7 +66,6 @@
 
   Polymer({
     is: 'gr-rule-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasRange: Boolean,
@@ -97,6 +96,11 @@
     behaviors: [
       Gerrit.AccessBehavior,
       Gerrit.BaseUrlBehavior,
+      /**
+       * Unused in this element, but called by other elements in tests
+       * e.g gr-permission_test.
+       */
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
@@ -115,11 +119,20 @@
       this._setupValues(this.rule);
     },
 
+    attached() {
+      if (!this.rule) { return; } // Check needed for test purposes.
+      if (!this._originalRuleValues) {
+        // Observer _handleValueChange is called after the ready()
+        // method finishes. Original values must be set later to
+        // avoid set .modified flag to true
+        this._setOriginalRuleValues(this.rule.value);
+      }
+    },
+
     _setupValues(rule) {
       if (!rule.value) {
         this._setDefaultRuleValues();
       }
-      this._setOriginalRuleValues(rule.value);
     },
 
     _computeForce(permission, action) {
@@ -211,12 +224,13 @@
 
     _handleRemoveRule() {
       if (this.rule.value.added) {
-        this.dispatchEvent(new CustomEvent('added-rule-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-rule-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.rule.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
@@ -238,7 +252,8 @@
       if (!this._originalRuleValues) { return; }
       this.rule.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _setOriginalRuleValues(value) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 7f5bf6a..6d533af 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rule-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-rule-editor.html">
 
@@ -200,7 +202,7 @@
     });
 
     suite('already existing generic rule', () => {
-      setup(() => {
+      setup(done => {
         element.group = 'Group Name';
         element.permission = 'submit';
         element.rule = {
@@ -216,6 +218,10 @@
         // by the parent element.
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -311,7 +317,7 @@
     });
 
     suite('new edit rule', () => {
-      setup(() => {
+      setup(done => {
         element.group = 'Group Name';
         element.permission = 'editTopicName';
         element.rule = {
@@ -321,6 +327,10 @@
         element._setupValues(element.rule);
         flushAsynchronousOperations();
         element.rule.value.added = true;
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -360,7 +370,7 @@
     });
 
     suite('already existing rule with labels', () => {
-      setup(() => {
+      setup(done => {
         element.label = {values: [
           {value: -2, text: 'This shall not be merged'},
           {value: -1, text: 'I would prefer this is not merged as is'},
@@ -382,6 +392,10 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -414,7 +428,7 @@
     });
 
     suite('new rule with labels', () => {
-      setup(() => {
+      setup(done => {
         sandbox.spy(element, '_setDefaultRuleValues');
         element.label = {values: [
           {value: -2, text: 'This shall not be merged'},
@@ -432,6 +446,10 @@
         element._setupValues(element.rule);
         flushAsynchronousOperations();
         element.rule.value.added = true;
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -472,7 +490,7 @@
     });
 
     suite('already existing push rule', () => {
-      setup(() => {
+      setup(done => {
         element.group = 'Group Name';
         element.permission = 'push';
         element.rule = {
@@ -485,6 +503,10 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -513,7 +535,7 @@
     });
 
     suite('new push rule', () => {
-      setup(() => {
+      setup(done => {
         element.group = 'Group Name';
         element.permission = 'push';
         element.rule = {
@@ -523,6 +545,10 @@
         element._setupValues(element.rule);
         flushAsynchronousOperations();
         element.rule.value.added = true;
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -553,7 +579,7 @@
     });
 
     suite('already existing edit rule', () => {
-      setup(() => {
+      setup(done => {
         element.group = 'Group Name';
         element.permission = 'editTopicName';
         element.rule = {
@@ -566,6 +592,10 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        flush(() => {
+          element.attached();
+          done();
+        });
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index a6dde7a..a6c86bb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -19,7 +19,7 @@
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
@@ -29,6 +29,8 @@
 <link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 
 <dom-module id="gr-change-list-item">
   <template>
@@ -76,7 +78,7 @@
         display: inline-flex;
       }
       .status .comma {
-        padding-right: .2rem;
+        padding-right: var(--spacing-xs);
       }
       /* Used to hide the leading separator comma for statuses. */
       .status .comma:first-of-type {
@@ -85,7 +87,7 @@
       .size gr-tooltip-content {
         margin: -.4rem -.6rem;
         max-width: 2.5rem;
-        padding: .4rem .6rem;
+        padding: var(--spacing-m) var(--spacing-l);
       }
       a {
         color: inherit;
@@ -97,6 +99,8 @@
       }
       .u-monospace {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
       }
       .u-green {
         color: var(--vote-text-color-recommended);
@@ -104,10 +108,6 @@
       .u-red {
         color: var(--vote-text-color-disliked);
       }
-      .label.u-green:not(.u-monospace),
-      .label.u-red:not(.u-monospace) {
-        font-size: 1.2rem;
-      }
       .u-gray-background {
         background-color: var(--table-header-background-color);
       }
@@ -159,16 +159,14 @@
     <td class="cell owner"
         hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
       <gr-account-link
-          account="[[change.owner]]"
-          additional-text="[[_computeAccountStatusString(change.owner)]]"></gr-account-link>
+          account="[[change.owner]]"></gr-account-link>
     </td>
     <td class="cell assignee"
         hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
       <template is="dom-if" if="[[change.assignee]]">
         <gr-account-link
             id="assigneeAccountLink"
-            account="[[change.assignee]]"
-            additional-text="[[_computeAccountStatusString(change.assignee)]]"></gr-account-link>
+            account="[[change.assignee]]"></gr-account-link>
       </template>
       <template is="dom-if" if="[[!change.assignee]]">
         <span class="placeholder">--</span>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index ecc7532..8eb39891 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -26,7 +26,6 @@
 
   Polymer({
     is: 'gr-change-list-item',
-    _legacyUndefinedCheck: true,
 
     properties: {
       visibleChangeTableColumns: Array,
@@ -40,11 +39,6 @@
         type: String,
         computed: '_computeChangeURL(change)',
       },
-      needsReview: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: '_computeItemNeedsReview(change.reviewed)',
-      },
       statuses: {
         type: Array,
         computed: 'changeStatuses(change)',
@@ -78,10 +72,6 @@
       });
     },
 
-    _computeItemNeedsReview(reviewed) {
-      return !reviewed;
-    },
-
     _computeChangeURL(change) {
       return Gerrit.Nav.getUrlForChange(change);
     },
@@ -174,10 +164,6 @@
       return str;
     },
 
-    _computeAccountStatusString(account) {
-      return account && account.status ? `(${account.status})` : '';
-    },
-
     _computeSizeTooltip(change) {
       if (change.insertions + change.deletions === 0 ||
           isNaN(change.insertions + change.deletions)) {
@@ -214,6 +200,7 @@
       this.set('change.reviewed', newVal);
       this.dispatchEvent(new CustomEvent('toggle-reviewed', {
         bubbles: true,
+        composed: true,
         detail: {change: this.change, reviewed: newVal},
       }));
     },
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index 35f81bc..afae619 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-item</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -187,14 +189,6 @@
       };
       flushAsynchronousOperations();
       assert.isOk(element.$$('.assignee gr-account-link'));
-      assert.equal(Polymer.dom(element.root)
-          .querySelector('#assigneeAccountLink').additionalText, '(test)');
-    });
-
-    test('_computeAccountStatusString', () => {
-      assert.equal(element._computeAccountStatusString({}), '');
-      assert.equal(element._computeAccountStatusString({status: 'Working'}),
-          '(Working)');
     });
 
     test('TShirt sizing tooltip', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 48d5075..3ac1d5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -17,7 +17,8 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -35,7 +36,7 @@
       }
       .loading {
         color: var(--deemphasized-text-color);
-        padding: 1em var(--default-horizontal-margin);
+        padding: var(--spacing-l);
       }
       gr-change-list {
         width: 100%;
@@ -67,7 +68,7 @@
       @media only screen and (max-width: 50em) {
         .loading,
         .error {
-          padding: 0 var(--default-horizontal-margin);
+          padding: 0 var(--spacing-l);
         }
       }
     </style>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 6d05132..cf6da73 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -18,7 +18,7 @@
   'use strict';
 
   const LookupQueryPatterns = {
-    CHANGE_ID: /^\s*i?[0-9a-f]{8,40}\s*$/i,
+    CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
     CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
   };
 
@@ -31,7 +31,6 @@
 
   Polymer({
     is: 'gr-change-list-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -41,6 +40,7 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
@@ -162,8 +162,7 @@
           for (const query in LookupQueryPatterns) {
             if (LookupQueryPatterns.hasOwnProperty(query) &&
                 this._query.match(LookupQueryPatterns[query])) {
-              this._replaceCurrentLocation(
-                  Gerrit.Nav.getUrlForChange(changes[0]));
+              Gerrit.Nav.navigateToChange(changes[0]);
               return;
             }
           }
@@ -185,10 +184,6 @@
       });
     },
 
-    _replaceCurrentLocation(url) {
-      window.location.replace(url);
-    },
-
     _getChanges() {
       return this.$.restAPI.getChanges(this._changesPerPage, this._query,
           this._offset);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 3911364..54885cc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-change-list-view.html">
 
@@ -194,7 +196,6 @@
 
     suite('query based navigation', () => {
       setup(() => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForChange', () => '/r/c/1');
       });
 
       teardown(done => {
@@ -205,10 +206,11 @@
       });
 
       test('Searching for a change ID redirects to change', done => {
+        const change = {_number: 1};
         sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(element, '_replaceCurrentLocation', url => {
-          assert.equal(url, '/r/c/1');
+            .returns(Promise.resolve([change]));
+        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+          assert.equal(url, change);
           done();
         });
 
@@ -216,10 +218,11 @@
       });
 
       test('Searching for a change num redirects to change', done => {
+        const change = {_number: 1};
         sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(element, '_replaceCurrentLocation', url => {
-          assert.equal(url, '/r/c/1');
+            .returns(Promise.resolve([change]));
+        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+          assert.equal(url, change);
           done();
         });
 
@@ -227,10 +230,11 @@
       });
 
       test('Commit hash redirects to change', done => {
+        const change = {_number: 1};
         sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(element, '_replaceCurrentLocation', url => {
-          assert.equal(url, '/r/c/1');
+            .returns(Promise.resolve([change]));
+        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+          assert.equal(url, change);
           done();
         });
 
@@ -240,7 +244,7 @@
       test('Searching for an invalid change ID searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([]));
-        const stub = sandbox.stub(element, '_replaceCurrentLocation');
+        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
 
         element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
         flushAsynchronousOperations();
@@ -251,7 +255,7 @@
       test('Change ID with multiple search results searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{}, {}]));
-        const stub = sandbox.stub(element, '_replaceCurrentLocation');
+        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
 
         element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
         flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index ef17baa..699f07a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -20,12 +20,14 @@
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.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/gr-change-list-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 
 <dom-module id="gr-change-list">
   <template>
@@ -35,6 +37,18 @@
         border-collapse: collapse;
         width: 100%;
       }
+      .section-count-label {
+        color: var(--deemphasized-text-color);
+      }
+      a.section-title:hover {
+        text-decoration: none;
+      }
+      a.section-title:hover .section-count-label {
+        text-decoration: none;
+      }
+      a.section-title:hover .section-name {
+        text-decoration: underline;
+      }
     </style>
     <table id="changeList">
       <tr class="topHeader">
@@ -68,8 +82,9 @@
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-              <a href$="[[_sectionHref(changeSection.query)]]">
-                [[changeSection.name]]
+              <a href$="[[_sectionHref(changeSection.query)]]" class="section-title">
+                <span class="section-name">[[changeSection.name]]</span>
+                <span class="section-count-label">[[changeSection.countLabel]]</span>
               </a>
             </td>
           </tr>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 587719d..5006f1e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -24,7 +24,6 @@
 
   Polymer({
     is: 'gr-change-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when next page key shortcut was pressed.
@@ -105,6 +104,7 @@
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.ChangeTableBehavior,
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.RESTClientBehavior,
       Gerrit.URLEncodingBehavior,
@@ -158,6 +158,11 @@
     },
 
     _computePreferences(account, preferences) {
+      // Polymer 2: check for undefined
+      if ([account, preferences].some(arg => arg === undefined)) {
+        return;
+      }
+
       this.changeTableColumns = this.columnNames;
 
       if (account) {
@@ -173,6 +178,7 @@
     },
 
     _computeColspan(changeTableColumns, labelNames) {
+      if (!changeTableColumns || !labelNames) return;
       return changeTableColumns.length + labelNames.length +
           NUMBER_FIXED_COLUMNS;
     },
@@ -210,8 +216,19 @@
       this.sections = changes ? [{results: changes}] : [];
     },
 
+    _processQuery(query) {
+      let tokens = query.split(' ');
+      const invalidTokens = ['limit:', 'age:', '-age:'];
+      tokens = tokens.filter(token => {
+        return !invalidTokens.some(invalidToken => {
+          return token.startsWith(invalidToken);
+        });
+      });
+      return tokens.join(' ');
+    },
+
     _sectionHref(query) {
-      return Gerrit.Nav.getUrlForSearchQuery(query);
+      return Gerrit.Nav.getUrlForSearchQuery(this._processQuery(query));
     },
 
     /**
@@ -238,13 +255,13 @@
     _computeItemNeedsReview(account, change, showReviewedState) {
       return showReviewedState && !change.reviewed &&
           !change.work_in_progress &&
-          this.changeIsOpen(change.status) &&
+          this.changeIsOpen(change) &&
           (!account || account._account_id != change.owner._account_id);
     },
 
     _computeItemHighlight(account, change) {
       // Do not show the assignee highlight if the change is not open.
-      if (!change.assignee ||
+      if (!change ||!change.assignee ||
           !account ||
           CLOSED_STATUS.indexOf(change.status) !== -1) {
         return false;
@@ -352,16 +369,16 @@
     },
 
     _getListItems() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-change-list-item'));
     },
 
     _sectionsChanged() {
       // Flush DOM operations so that the list item elements will be loaded.
-      Polymer.dom.flush();
-      this.$.cursor.stops = this._getListItems();
-      this.$.cursor.moveToStart();
+      Polymer.RenderStatus.afterNextRender(this, () => {
+        this.$.cursor.stops = this._getListItems();
+        this.$.cursor.moveToStart();
+      });
     },
 
     _isOutgoing(section) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 99741a8..817de6f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-change-list.html">
 
@@ -170,11 +172,11 @@
         {_number: 2},
       ];
       flushAsynchronousOperations();
-      const elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 3);
+      Polymer.RenderStatus.afterNextRender(element, () => {
+        const elementItems = Polymer.dom(element.root).querySelectorAll(
+            'gr-change-list-item');
+        assert.equal(elementItems.length, 3);
 
-      flush(() => {
         assert.isTrue(elementItems[0].hasAttribute('selected'));
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.selectedIndex, 1);
@@ -431,6 +433,59 @@
     });
   });
 
+  suite('dashboard queries', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), query);
+    });
+
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+  });
+
   suite('gr-change-list sections', () => {
     let element;
     let sandbox;
@@ -442,7 +497,7 @@
 
     teardown(() => { sandbox.restore(); });
 
-    test('keyboard shortcuts', () => {
+    test('keyboard shortcuts', done => {
       element.selectedIndex = 0;
       element.sections = [
         {
@@ -468,42 +523,45 @@
         },
       ];
       flushAsynchronousOperations();
-      const elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 9);
+      Polymer.RenderStatus.afterNextRender(element, () => {
+        const elementItems = Polymer.dom(element.root).querySelectorAll(
+            'gr-change-list-item');
+        assert.equal(elementItems.length, 9);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
 
-      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
+        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+        assert.equal(element.selectedIndex, 2);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+            'Should navigate to /c/2/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
+        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+            'Should navigate to /c/1/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-      assert.equal(element.selectedIndex, 4);
-      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-          'Should navigate to /c/4/');
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 4);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+            'Should navigate to /c/4/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-      const change = element._changeForIndex(element.selectedIndex);
-      assert.equal(change.reviewed, true,
-          'Should mark change as reviewed');
-      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-      assert.equal(change.reviewed, false,
-          'Should mark change as unreviewed');
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        const change = element._changeForIndex(element.selectedIndex);
+        assert.equal(change.reviewed, true,
+            'Should mark change as reviewed');
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        assert.equal(change.reviewed, false,
+            'Should mark change as unreviewed');
+        done();
+      });
     });
 
     test('highlight attribute is updated correctly', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
index ecbd67e..e88368d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -30,7 +30,7 @@
       #graphic,
       #help {
         display: inline-block;
-        margin: .5em;
+        margin: var(--spacing-m);
       }
       #graphic #circle {
         align-items: center;
@@ -51,14 +51,14 @@
         text-align: center;
       }
       #help {
-        padding-top: 1.35em;
+        padding-top: var(--spacing-xl);
         vertical-align: top;
       }
       #help h1 {
-        font-size: var(--font-size-large);
+        font-size: var(--font-size-h3);
       }
       #help p {
-        margin-bottom: .6em;
+        margin-bottom: var(--spacing-m);
         max-width: 35em;
       }
       @media only screen and (max-width: 50em) {
@@ -82,7 +82,7 @@
         other git code review tools. Click on the `Create Change' button
         and follow the step by step instructions.
       </p>
-      <gr-button on-tap="_handleCreateTap">Create Change</gr-button>
+      <gr-button on-click="_handleCreateTap">Create Change</gr-button>
     </div>
   </template>
   <script src="gr-create-change-help.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
index 42d7bd7..19e7a25 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-create-change-help',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the "Create change" button is tapped.
@@ -29,7 +28,8 @@
 
     _handleCreateTap(e) {
       e.preventDefault();
-      this.dispatchEvent(new CustomEvent('create-tap', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('create-tap', {bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
index 09d95fd..c43d62a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-help</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
index e6d123c..9e86058 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
@@ -25,10 +25,10 @@
     <style include="shared-styles">
       ol {
         list-style: decimal;
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       p {
-        margin-bottom: .75em;
+        margin-bottom: var(--spacing-m);
       }
       #commandsDialog {
         max-width: 40em;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
index e4958c5..5abb257 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
@@ -25,7 +25,7 @@
 
   Polymer({
     is: 'gr-create-commands-dialog',
-    _legacyUndefinedCheck: true,
+
     properties: {
       branch: String,
       _createNewCommitCommand: {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
index e00037d..89ad573 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-commands-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-create-commands-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
index d12d84b..def5228 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-repo-branch-picker/gr-repo-branch-picker.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
index ed87e9e..c2bfbf5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
@@ -26,7 +26,7 @@
 
   Polymer({
     is: 'gr-create-destination-dialog',
-    _legacyUndefinedCheck: true,
+
     properties: {
       _repo: String,
       _branch: String,
@@ -46,9 +46,13 @@
       this.$.createOverlay.close();
     },
 
-    _pickerConfirm() {
+    _pickerConfirm(e) {
       this.$.createOverlay.close();
       const detail = {repo: this._repo, branch: this._branch};
+      // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
+      // 'confirm' event here, so let's stop propagation of the bare event.
+      e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
     },
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index b0ba8a2b..41475a0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
@@ -38,7 +39,7 @@
       }
       .loading {
         color: var(--deemphasized-text-color);
-        padding: 1em var(--default-horizontal-margin);
+        padding: var(--spacing-l);
       }
       gr-change-list {
         width: 100%;
@@ -52,7 +53,7 @@
         border-bottom: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
-        padding: .25em var(--default-horizontal-margin);
+        padding: var(--spacing-xs) var(--spacing-l);
       }
       .banner gr-button {
         --gr-button: {
@@ -67,7 +68,7 @@
       }
       @media only screen and (max-width: 50em) {
         .loading {
-          padding: 0 var(--default-horizontal-margin);
+          padding: 0 var(--spacing-l);
         }
       }
     </style>
@@ -80,7 +81,7 @@
         <gr-button
             class="delete"
             link
-            on-tap="_handleOpenDeleteDialog">Delete All</gr-button>
+            on-click="_handleOpenDeleteDialog">Delete All</gr-button>
       </div>
     </div>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 5faef08..b762ab3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-dashboard-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -76,6 +75,7 @@
     ],
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -144,12 +144,6 @@
         return Promise.resolve();
       }
 
-      const user = params.user || 'self';
-
-      // NOTE: This method may be called before attachment. Fire title-change
-      // in an async so that attachment to the DOM can take place first.
-      const title = params.title || this._computeTitle(user);
-      this.async(() => this.fire('title-change', {title}));
       return this._reload();
     },
 
@@ -170,11 +164,19 @@
 
       const checkForNewUser = !project && user === 'self';
       return dashboardPromise
-          .then(res => this._fetchDashboardChanges(res, checkForNewUser))
+          .then(res => {
+            if (res && res.title) {
+              this.fire('title-change', {title: res.title});
+            }
+            return this._fetchDashboardChanges(res, checkForNewUser);
+          })
           .then(() => {
             this._maybeShowDraftsBanner();
             this.$.reporting.dashboardDisplayed();
           }).catch(err => {
+            this.fire('title-change', {
+              title: title || this._computeTitle(user),
+            });
             console.warn(err);
           }).then(() => { this._loading = false; });
     },
@@ -196,7 +198,7 @@
             section.query);
 
       if (checkForNewUser) {
-        queries.push('owner:self');
+        queries.push('owner:self limit:1');
       }
 
       return this.$.restAPI.getChanges(null, queries, null, this.options)
@@ -208,6 +210,7 @@
             }
             this._results = changes.map((results, i) => ({
               name: res.sections[i].name,
+              countLabel: this._computeSectionCountLabel(results),
               query: res.sections[i].query,
               results,
               isOutgoing: res.sections[i].isOutgoing,
@@ -217,6 +220,16 @@
           });
     },
 
+    _computeSectionCountLabel(changes) {
+      if (!changes || !changes.length || changes.length == 0) {
+        return '';
+      }
+      const more = changes[changes.length - 1]._more_changes;
+      const numChanges = changes.length;
+      const andMore = more ? ' and more' : '';
+      return `(${numChanges}${andMore})`;
+    },
+
     _computeUserHeaderClass(params) {
       if (!params || !!params.project || !params.user
           || params.user === 'self') {
@@ -248,7 +261,7 @@
       if (!draftSection || !draftSection.results.length) { return; }
 
       const closedChanges = draftSection.results
-          .filter(change => !this.changeIsOpen(change.status));
+          .filter(change => !this.changeIsOpen(change));
       if (!closedChanges.length) { return; }
 
       this._showDraftsBanner = true;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 54c8ea9..41d4192 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dashboard-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-dashboard-view.html">
 
@@ -147,6 +149,27 @@
       assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
     });
 
+    suite('_computeSectionCountLabel', () => {
+      test('empty changes dont count label', () => {
+        assert.equal('', element._computeSectionCountLabel([]));
+      });
+
+      test('1 change', () => {
+        assert.equal('(1)',
+            element._computeSectionCountLabel(['1']));
+      });
+
+      test('2 changes', () => {
+        assert.equal('(2)',
+            element._computeSectionCountLabel(['1', '2']));
+      });
+
+      test('1 change and more', () => {
+        assert.equal('(1 and more)',
+            element._computeSectionCountLabel([{_more_changes: true}]));
+      });
+    });
+
     suite('_isViewActive', () => {
       test('nothing happens when user param is falsy', () => {
         element.params = {};
@@ -182,7 +205,7 @@
         return paramsChangedPromise.then(() => {
           assert.isTrue(
               getChangesStub.calledWith(
-                  null, ['1', '2', 'owner:self'], null, element.options));
+                  null, ['1', '2', 'owner:self limit:1'], null, element.options));
         });
       });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
index 2394e24..d445185 100644
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
index 14d0cb0..acc4295 100644
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-embed-dashboard',
-    _legacyUndefinedCheck: true,
+
     properties: {
       account: Object,
       sections: Array,
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
index 2328725..0b4459c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/dashboard-header-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
index 67fbd97..7ae4dab 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-repo-header',
-    _legacyUndefinedCheck: true,
+
     properties: {
       /** @type {?String} */
       repo: {
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
index a561e09..266818e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-header</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo-header.html">
 
@@ -44,30 +46,13 @@
 
     teardown(() => { sandbox.restore(); });
 
-    test('loads and clears account info', done => {
-      sandbox.stub(element.$.restAPI, 'getAccountDetails')
-          .returns(Promise.resolve({
-            name: 'foo',
-            email: 'bar',
-            registered_on: '2015-03-12 18:32:08.000000000',
-          }));
-      sandbox.stub(element.$.restAPI, 'getAccountStatus')
-          .returns(Promise.resolve('baz'));
-
-      element.userId = 'foo.bar@baz';
-      flush(() => {
-        assert.isOk(element._accountDetails);
-        assert.isOk(element._status);
-
-        element.userId = null;
-        flush(() => {
-          flushAsynchronousOperations();
-          assert.isNull(element._accountDetails);
-          assert.isNull(element._status);
-
-          done();
-        });
-      });
+    test('repoUrl reset once repo changed', () => {
+      sandbox.stub(Gerrit.Nav, 'getUrlForRepo',
+          repoName => `http://test.com/${repoName}`
+      );
+      assert.equal(element._repoUrl, undefined);
+      element.repo = 'test';
+      assert.equal(element._repoUrl, 'http://test.com/test');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index 6accdfc..fed1c12 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index c7fda2c..6afc169 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-user-header',
-    _legacyUndefinedCheck: true,
+
     properties: {
       /** @type {?String} */
       userId: {
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
index c33be3b..e837a5b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-user-header</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-user-header.html">
 
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 c53c111..0000000
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ /dev/null
@@ -1,185 +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,
-    ],
-
-    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}));
-      }
-    },
-
-    _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 724f7da..0000000
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ /dev/null
@@ -1,274 +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="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../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-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 278875e..e12f10d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../admin/gr-create-change-dialog/gr-create-change-dialog.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
@@ -62,13 +62,13 @@
         color: var(--deemphasized-text-color);
       }
       #confirmSubmitDialog .changeSubject {
-        margin: 1em;
+        margin: var(--spacing-l);
         text-align: center;
       }
       iron-icon {
         color: inherit;
         height: 1.2rem;
-        margin-right: .2rem;
+        margin-right: var(--spacing-xs);
         width: 1.2rem;
       }
       gr-button {
@@ -92,7 +92,7 @@
         }
         gr-button {
           --gr-button: {
-            padding: .5em;
+            padding: var(--spacing-m);
             white-space: nowrap;
           }
         }
@@ -101,7 +101,7 @@
           margin: 0;
         }
         #actionLoadingMessage {
-          margin: .5em;
+          margin: var(--spacing-m);
           text-align: center;
         }
         #moreMessage {
@@ -124,11 +124,12 @@
                 link
                 title$="[[action.title]]"
                 has-tooltip="[[_computeHasTooltip(action.title)]]"
+                position-below="true"
                 data-action-key$="[[action.__key]]"
                 data-action-type$="[[action.__type]]"
                 data-label$="[[action.label]]"
                 disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-tap="_handleActionTap">
+                on-click="_handleActionTap">
                 <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
               [[action.label]]
             </gr-button>
@@ -144,11 +145,12 @@
                 link
                 title$="[[action.title]]"
                 has-tooltip="[[_computeHasTooltip(action.title)]]"
+                position-below="true"
                 data-action-key$="[[action.__key]]"
                 data-action-type$="[[action.__type]]"
                 data-label$="[[action.label]]"
                 disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-tap="_handleActionTap">
+                on-click="_handleActionTap">
               <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
               [[action.label]]
             </gr-button>
@@ -177,7 +179,7 @@
           on-cancel="_handleConfirmDialogCancel"
           branch="[[change.branch]]"
           has-parent="[[hasParent]]"
-          rebase-on-current="[[revisionActions.rebase.rebaseOnCurrent]]"
+          rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
           hidden></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick"
           class="confirmDialog"
@@ -217,7 +219,7 @@
           id="confirmSubmitDialog"
           class="confirmDialog"
           change="[[change]]"
-          action="[[revisionActions.submit]]"
+          action="[[_revisionSubmitAction]]"
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleSubmitConfirm" hidden></gr-confirm-submit-dialog>
       <gr-dialog id="createFollowUpDialog"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 9182bbc..9c0c38f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -50,7 +50,6 @@
     OPTIONAL: 'OPTIONAL',
   };
 
-  // TODO(davido): Add the rest of the change actions.
   const ChangeActions = {
     ABANDON: 'abandon',
     DELETE: '/',
@@ -72,7 +71,6 @@
     WIP: 'wip',
   };
 
-  // TODO(andybons): Add the rest of the revision actions.
   const RevisionActions = {
     CHERRYPICK: 'cherrypick',
     REBASE: 'rebase',
@@ -193,7 +191,6 @@
 
   Polymer({
     is: 'gr-change-actions',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the change should be reloaded.
@@ -269,8 +266,23 @@
       /** @type {?} */
       revisionActions: {
         type: Object,
+        notify: true,
         value() { return {}; },
       },
+      // If property binds directly to [[revisionActions.submit]] it is not
+      // updated when revisionActions doesn't contain submit action.
+      /** @type {?} */
+      _revisionSubmitAction: {
+        type: Object,
+        computed: '_getSubmitAction(revisionActions)',
+      },
+      // If property binds directly to [[revisionActions.rebase]] it is not
+      // updated when revisionActions doesn't contain rebase action.
+      /** @type {?} */
+      _revisionRebaseAction: {
+        type: Object,
+        computed: '_getRebaseAction(revisionActions)',
+      },
       privateByDefault: String,
 
       _loading: {
@@ -394,6 +406,7 @@
     RevisionActions,
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
@@ -415,6 +428,26 @@
       this._handleLoadingComplete();
     },
 
+    _getSubmitAction(revisionActions) {
+      return this._getRevisionAction(revisionActions, 'submit', null);
+    },
+
+    _getRebaseAction(revisionActions) {
+      return this._getRevisionAction(revisionActions, 'rebase', null);
+    },
+
+    _getRevisionAction(revisionActions, actionName, emptyActionValue) {
+      if (!revisionActions) {
+        return undefined;
+      }
+      if (revisionActions[actionName] === undefined) {
+        // Return null to fire an event when reveisionActions was loaded
+        // but doesn't contain actionName. undefined doesn't fire an event
+        return emptyActionValue;
+      }
+      return revisionActions[actionName];
+    },
+
     reload() {
       if (!this.changeNum || !this.latestPatchNum) {
         return Promise.resolve();
@@ -425,6 +458,10 @@
         if (!revisionActions) { return; }
 
         this.revisionActions = revisionActions;
+        this._sendShowRevisionActions({
+          change: this.change,
+          revisionActions,
+        });
         this._handleLoadingComplete();
       }).catch(err => {
         this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
@@ -437,6 +474,13 @@
       Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
     },
 
+    _sendShowRevisionActions(detail) {
+      this.$.jsAPI.handleEvent(
+          this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
+          detail
+      );
+    },
+
     _changeChanged() {
       this.reload();
     },
@@ -553,6 +597,15 @@
 
     _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
         additionalActionsChangeRecord) {
+      // Polymer 2: check for undefined
+      if ([
+        actionsChangeRecord,
+        revisionActionsChangeRecord,
+        additionalActionsChangeRecord,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       const additionalActions = (additionalActionsChangeRecord &&
           additionalActionsChangeRecord.base) || [];
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
@@ -573,14 +626,25 @@
      * @param {string=} actionName
      */
     _deleteAndNotify(actionName) {
-      if (this.actions[actionName]) {
+      if (this.actions && this.actions[actionName]) {
         delete this.actions[actionName];
-        this.notifyPath('actions.' + actionName);
+        // We assign a fake value of 'false' to support Polymer 2
+        // see https://github.com/Polymer/polymer/issues/2631
+        this.notifyPath('actions.' + actionName, false);
       }
     },
 
     _editStatusChanged(editMode, editPatchsetLoaded,
         editBasedOnCurrentPatchSet, disableEdit) {
+      // Polymer 2: check for undefined
+      if ([
+        editMode,
+        editBasedOnCurrentPatchSet,
+        disableEdit,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (disableEdit) {
         this._deleteAndNotify('publishEdit');
         this._deleteAndNotify('rebaseEdit');
@@ -589,10 +653,10 @@
         this._deleteAndNotify('edit');
         return;
       }
-      if (editPatchsetLoaded) {
+      if (this.actions && editPatchsetLoaded) {
         // Only show actions that mutate an edit if an actual edit patch set
         // is loaded.
-        if (this.changeIsOpen(this.change.status)) {
+        if (this.changeIsOpen(this.change)) {
           if (editBasedOnCurrentPatchSet) {
             if (!this.actions.publishEdit) {
               this.set('actions.publishEdit', PUBLISH_EDIT);
@@ -614,7 +678,7 @@
         this._deleteAndNotify('deleteEdit');
       }
 
-      if (this.changeIsOpen(this.change.status)) {
+      if (this.actions && this.changeIsOpen(this.change)) {
         // Only show edit button if there is no edit patchset loaded and the
         // file list is not in edit mode.
         if (editPatchsetLoaded || editMode) {
@@ -843,7 +907,7 @@
     _handleActionTap(e) {
       e.preventDefault();
       let el = Polymer.dom(e).localTarget;
-      while (el.is !== 'gr-button') {
+      while (el.tagName.toLowerCase() !== 'gr-button') {
         if (!el.parentElement) { return; }
         el = el.parentElement;
       }
@@ -967,8 +1031,9 @@
     },
 
     _calculateDisabled(action, hasKnownChainState) {
-      if (action.__key === 'rebase' && hasKnownChainState === false) {
-        return true;
+      if (action.__key === 'rebase') {
+        // Rebase button is only disabled when change has no parent(s).
+        return hasKnownChainState === false;
       }
       return !action.enabled;
     },
@@ -1003,7 +1068,6 @@
     _handleCherryPickRestApi(conflicts) {
       const el = this.$.confirmCherrypick;
       if (!el.branch) {
-        // TODO(davido): Fix error handling
         this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
         return;
       }
@@ -1307,6 +1371,17 @@
      */
     _computeAllActions(changeActionsRecord, revisionActionsRecord,
         primariesRecord, additionalActionsRecord, change) {
+      // Polymer 2: check for undefined
+      if ([
+        changeActionsRecord,
+        revisionActionsRecord,
+        primariesRecord,
+        additionalActionsRecord,
+        change,
+      ].some(arg => arg === undefined)) {
+        return [];
+      }
+
       const revisionActionValues = this._getActionValues(revisionActionsRecord,
           primariesRecord, additionalActionsRecord, ActionType.REVISION);
       const changeActionValues = this._getActionValues(changeActionsRecord,
@@ -1395,6 +1470,13 @@
       });
     },
 
+    _computeRebaseOnCurrent(revisionRebaseAction) {
+      if (revisionRebaseAction) {
+        return !!revisionRebaseAction.enabled;
+      }
+      return null;
+    },
+
     /**
      * Occasionally, a change created by a change action is not yet knwon to the
      * API for a brief time. Wait for the given change number to be recognized.
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 9803fab..74d262a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -112,6 +114,15 @@
       sandbox.restore();
     });
 
+    test('show-revision-actions event should fire', done => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      flush(() => {
+        assert.isTrue(spy.called);
+        done();
+      });
+    });
+
     test('primary and secondary actions split properly', () => {
       // Submit should be the only primary action.
       assert.equal(element._topLevelPrimaryActions.length, 1);
@@ -352,7 +363,7 @@
 
       action.enabled = false;
       assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
+          element._calculateDisabled(action, hasKnownChainState), false);
     });
 
     test('rebase change', done => {
@@ -1511,4 +1522,69 @@
       assert.equal(reportStub.lastCall.args[0], 'type-key');
     });
   });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element;
+    let sandbox;
+    let changeRevisionActions;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve(changeRevisionActions);
+        },
+        send(method, url, payload) {
+          return Promise.reject(new Error('error'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+
+      element = fixture('basic');
+      // getChangeRevisionActions is not called without
+      // set the following properies
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+
+
+      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sandbox.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: 'POST',
+        title: 'Rebase onto tip of branch or parent change',
+      };
+
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
+
+      delete rebaseAction.enabled;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
index f1d3cd3..b8bea9ca 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="gr-change-metadata.html">
@@ -87,6 +89,7 @@
 
     teardown(() => {
       sandbox.restore();
+      Gerrit._testOnly_resetPlugins();
     });
 
     suite('by default', () => {
@@ -104,7 +107,7 @@
 
     suite('with plugin style', () => {
       setup(done => {
-        Gerrit._resetPlugins();
+        Gerrit._testOnly_resetPlugins();
         const pluginHost = fixture('plugin-host');
         pluginHost.config = {
           plugin: {
@@ -139,7 +142,7 @@
             new URL('test/plugin.html?' + Math.random(),
                 window.location.href).toString());
         sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
         element = createElement();
       });
 
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 a09b730..6a92d96 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
@@ -15,9 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-change-metadata-shared-styles.html">
+<link rel="import" href="../../../styles/gr-change-view-integration-shared-styles.html">
 <link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
@@ -35,38 +37,17 @@
 <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">
+<link rel="import" href="../../shared/gr-account-list/gr-account-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>
+    <style include="gr-change-metadata-shared-styles"></style>
     <style include="shared-styles">
       :host {
         display: table;
       }
-      section {
-        display: table-row;
-      }
-      section:not(:first-of-type) .title,
-      section:not(:first-of-type) .value {
-        padding-top: .5em;
-      }
-      section:not(:first-of-type) {
-        margin-top: 1em;
-      }
-      .title,
-      .value {
-        display: table-cell;
-      }
-      .title {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        max-width: 20em;
-        padding-left: var(--metadata-horizontal-padding);
-        padding-right: .5em;
-        word-break: break-word;
-      }
-      .value {
-        padding-right: var(--metadata-horizontal-padding);
-      }
       gr-change-requirements {
         --requirements-horizontal-padding: var(--metadata-horizontal-padding);
       }
@@ -74,7 +55,7 @@
         max-width: 20ch;
         overflow: hidden;
         text-overflow: ellipsis;
-        vertical-align: middle;
+        vertical-align: top;
         white-space: nowrap;
       }
       gr-editable-label {
@@ -99,14 +80,14 @@
         pointer-events: none;
       }
       .hashtagChip {
-        margin-bottom: .5em;
+        margin-bottom: var(--spacing-m);
       }
       #externalStyle {
         display: block;
       }
       .parentList.merge {
         list-style-type: decimal;
-        padding-left: 1em;
+        padding-left: var(--spacing-l);
       }
       .parentList gr-commit-info {
         display: inline-block;
@@ -116,7 +97,7 @@
         display: none;
       }
       .icon {
-        margin: -.25em 0;
+        margin: -3px 0;
       }
       .icon.help,
       .icon.notTrusted {
@@ -133,9 +114,12 @@
         display: inline-block;
       }
       .separatedSection {
-        border-top: 1px solid var(--border-color);
-        margin-top: .5em;
-        padding: .5em 0;
+        margin-top: var(--spacing-l);
+        padding: var(--spacing-m) 0;
+      }
+      .hashtag gr-linked-chip,
+      .topic gr-linked-chip {
+        --linked-chip-text-color: var(--link-color);
       }
     </style>
     <gr-external-style id="externalStyle" name="change-metadata">
@@ -195,9 +179,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 651bc24..8f3f54b 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
@@ -17,17 +17,6 @@
 (function() {
   'use strict';
 
-  const Defs = {};
-
-  /**
-   * @typedef {{
-   *    message: string,
-   *    icon: string,
-   *    class: string,
-   *  }}
-   */
-  Defs.PushCertificateValidation;
-
   const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
   const SubmitTypeLabel = {
@@ -61,7 +50,6 @@
 
   Polymer({
     is: 'gr-change-metadata',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the change topic is changed.
@@ -101,7 +89,7 @@
         computed: '_computeHashtagReadOnly(_mutable, change)',
       },
       /**
-       * @type {Defs.PushCertificateValidation}
+       * @type {Gerrit.PushCertificateValidation}
        */
       _pushCertificateValidation: {
         type: Object,
@@ -177,7 +165,7 @@
     },
 
     _computeHideStrategy(change) {
-      return !this.changeIsOpen(change.status);
+      return !this.changeIsOpen(change);
     },
 
     /**
@@ -215,19 +203,21 @@
             this._settingTopic = false;
             this.set(['change', 'topic'], newTopic);
             if (newTopic !== lastTopic) {
-              this.dispatchEvent(
-                  new CustomEvent('topic-changed', {bubbles: true}));
+              this.dispatchEvent(new CustomEvent(
+                  'topic-changed', {bubbles: true, composed: true}));
             }
           });
     },
 
     _showAddTopic(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      const hasTopic = !!changeRecord &&
+          !!changeRecord.base && !!changeRecord.base.topic;
       return !hasTopic && !settingTopic;
     },
 
     _showTopicChip(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      const hasTopic = !!changeRecord &&
+          !!changeRecord.base && !!changeRecord.base.topic;
       return hasTopic && !settingTopic;
     },
 
@@ -241,13 +231,15 @@
         this.set(['change', 'hashtags'], newHashtag);
         if (newHashtag !== lastHashtag) {
           this.dispatchEvent(
-              new CustomEvent('hashtag-changed', {bubbles: true}));
+              new CustomEvent('hashtag-changed', {
+                bubbles: true, composed: true}));
         }
       });
     },
 
     _computeTopicReadOnly(mutable, change) {
       return !mutable ||
+          !change ||
           !change.actions ||
           !change.actions.topic ||
           !change.actions.topic.enabled;
@@ -255,6 +247,7 @@
 
     _computeHashtagReadOnly(mutable, change) {
       return !mutable ||
+          !change ||
           !change.actions ||
           !change.actions.hashtags ||
           !change.actions.hashtags.enabled;
@@ -262,6 +255,7 @@
 
     _computeAssigneeReadOnly(mutable, change) {
       return !mutable ||
+          !change ||
           !change.actions ||
           !change.actions.assignee ||
           !change.actions.assignee.enabled;
@@ -291,11 +285,11 @@
     },
 
     /**
-     * @return {?Defs.PushCertificateValidation} object representing data for
+     * @return {?Gerrit.PushCertificateValidation} object representing data for
      *     the push validation.
      */
     _computePushCertificateValidation(serverConfig, change) {
-      if (!serverConfig || !serverConfig.receive ||
+      if (!change || !serverConfig || !serverConfig.receive ||
           !serverConfig.receive.enable_signed_push) {
         return null;
       }
@@ -348,6 +342,7 @@
     },
 
     _computeBranchURL(project, branch) {
+      if (!this.change || !this.change.status) return '';
       return Gerrit.Nav.getUrlForBranch(branch, project,
           this.change.status == this.ChangeStatus.NEW ? 'open' :
             this.change.status.toLowerCase());
@@ -368,7 +363,7 @@
         target.disabled = false;
         this.set(['change', 'topic'], '');
         this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true}));
+            new CustomEvent('topic-changed', {bubbles: true, composed: true}));
       }).catch(err => {
         target.disabled = false;
         return;
@@ -407,7 +402,7 @@
      * @return {Object|null} either an accound or null.
      */
     _getNonOwnerRole(change, role) {
-      if (!change.current_revision ||
+      if (!change || !change.current_revision ||
           !change.revisions[change.current_revision]) {
         return null;
       }
@@ -444,13 +439,18 @@
     },
 
     _computeParentsLabel(parents) {
-      return parents.length > 1 ? 'Parents' : 'Parent';
+      return parents && parents.length > 1 ? 'Parents' : 'Parent';
     },
 
     _computeParentListClass(parents, parentIsCurrent) {
+      // Undefined check for polymer 2
+      if (parents === undefined || parentIsCurrent === undefined) {
+        return '';
+      }
+
       return [
         'parentList',
-        parents.length > 1 ? 'merge' : 'nonMerge',
+        parents && parents.length > 1 ? 'merge' : 'nonMerge',
         parentIsCurrent ? 'current' : 'notCurrent',
       ].join(' ');
     },
@@ -465,5 +465,12 @@
       // dom-if.
       this.$$('.topicEditableLabel').open();
     },
+
+    _getReviewerSuggestionsProvider(change) {
+      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+      provider.init();
+      return provider;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 96c3c7f..ec78a15 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-change-metadata.html">
@@ -729,7 +731,7 @@
             },
             '0.1',
             'http://some/plugins/url.html');
-        Gerrit._setPluginsCount(0);
+        Gerrit._loadPlugins([]);
         flush(() => {
           assert.strictEqual(hookEl.plugin, plugin);
           assert.strictEqual(hookEl.change, element.change);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
index d0ed4a1..b3aa98f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
@@ -7,20 +7,22 @@
 </dom-module>
 
 <dom-module id="my-plugin-style">
-  <style>
-    html {
-      --change-metadata-assignee: {
-        display: none;
+  <template>
+    <style>
+      html {
+        --change-metadata-assignee: {
+          display: none;
+        }
+        --change-metadata-label-status: {
+          display: none;
+        }
+        --change-metadata-strategy: {
+          display: none;
+        }
+        --change-metadata-topic: {
+          display: none;
+        }
       }
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
-      --change-metadata-topic: {
-        display: none;
-      }
-    }
-  </style>
+    </style>
+  </template>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index e79bce1..47ff7f7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -34,8 +34,10 @@
       .status {
         color: #FFA62F;
         display: inline-block;
-        font-family: var(--monospace-font-family);
         text-align: center;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
       }
       .approved.status {
         color: var(--vote-text-color-recommended);
@@ -46,8 +48,8 @@
       iron-icon {
         color: inherit;
       }
-      .name {
-        font-weight: var(--font-weight-bold);
+      .status iron-icon {
+        vertical-align: top;
       }
       section {
         display: table-row;
@@ -57,16 +59,15 @@
       }
       .title {
         min-width: 10em;
-        padding: .75em .5em 0 var(--requirements-horizontal-padding);
-        vertical-align: top;
+        padding: var(--spacing-s) var(--spacing-m) 0 var(--requirements-horizontal-padding);
       }
       .value {
-        padding: .6em .5em 0 0;
-        vertical-align: middle;
+        padding: var(--spacing-s) 0 0 0;
       }
       .title,
       .value {
         display: table-cell;
+        vertical-align: top;
       }
       .hidden {
         display: none;
@@ -75,12 +76,10 @@
         cursor: pointer;
       }
       .showHide .title {
-        border-top: 1px solid var(--border-color);
-        padding-bottom: .5em;
-        padding-top: .5em;
+        padding-bottom: var(--spacing-m);
+        padding-top: var(--spacing-l);
       }
       .showHide .value {
-        border-top: 1px solid var(--border-color);
         padding-top: 0;
         vertical-align: middle;
       }
@@ -89,7 +88,7 @@
         float: right;
       }
       .spacer {
-        height: .5em;
+        height: var(--spacing-m);
       }
     </style>
     <template
@@ -128,7 +127,7 @@
     <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
     <section
         show-bottom-border$="[[_showOptionalLabels]]"
-        on-tap="_handleShowHide"
+        on-click="_handleShowHide"
         class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
       <div class="title">Other labels</div>
       <div class="value">
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 4717ff9..dfdcd59 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-change-requirements',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index 3f35158..2ceac39 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-requirements</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-change-requirements.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 8579762..29f2d83 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -15,11 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
+<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
@@ -42,6 +43,7 @@
 <link rel="import" href="../../shared/revision-info/revision-info.html">
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
 <link rel="import" href="../gr-file-list-header/gr-file-list-header.html">
@@ -61,25 +63,25 @@
       }
       .container.loading {
         color: var(--deemphasized-text-color);
-        padding: 1em var(--default-horizontal-margin);
+        padding: var(--spacing-l);
       }
       .header {
         align-items: center;
         background-color: var(--table-header-background-color);
         border-bottom: 1px solid var(--border-color);
         display: flex;
-        padding: .55em var(--default-horizontal-margin);
+        padding: var(--spacing-s) var(--spacing-l);
         z-index: 99;  /* Less than gr-overlay's backdrop */
       }
       .header.editMode {
         background-color: var(--edit-mode-background-color);
       }
       .header .download {
-        margin-right: 1em;
+        margin-right: var(--spacing-l);
       }
       gr-change-status {
         display: initial;
-        margin: .1em .1em .1em .4em;
+        margin: var(--spacing-xxs) var(--spacing-xxs) var(--spacing-xxs) var(--spacing-s);
       }
       gr-change-status:first-child {
         margin-left: 0;
@@ -88,17 +90,16 @@
         align-items: center;
         display: flex;
         flex: 1;
-        font-size: 1.2rem;
+        font-size: var(--font-size-h3);
       }
       .headerTitle .headerSubject {
         font-weight: var(--font-weight-bold);
       }
       #replyBtn {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       gr-change-star {
-        font-size: var(--font-size-normal);
-        margin-right: .25em;
+        margin-right: var(--spacing-xs);
       }
       gr-reply-dialog {
         width: 60em;
@@ -106,6 +107,9 @@
       .changeStatus {
         text-transform: capitalize;
       }
+      .changeInfo {
+        background-color: var(--table-header-background-color);
+      }
       /* Strong specificity here is needed due to
          https://github.com/Polymer/polymer/issues/2531 */
       .container section.changeInfo {
@@ -114,32 +118,33 @@
       .changeId {
         color: var(--deemphasized-text-color);
         font-family: var(--font-family);
-        margin-top: 1em;
+        margin-top: var(--spacing-l);
       }
       .changeMetadata {
-        border-right: 1px solid var(--border-color);
-      }
-      /* Prevent plugin text from overflowing. */
-      #change_plugins {
-        word-break: break-word;
+        /* Limit meta section to half of the screen at max */
+        max-width: 50%;
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
-        margin-right: 1em;
-        margin-bottom: 1em;
-        max-width: var(--commit-message-max-width, 72ch);;
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        margin-right: var(--spacing-l);
+        margin-bottom: var(--spacing-l);
+        /* Account for border and padding and rounding errors. */
+        max-width: calc(72ch + 2px + 2*var(--spacing-m) + 0.4px);
       }
       .commitMessage gr-linked-text {
         word-break: break-word;
       }
       #commitMessageEditor {
-        min-width: 72ch;
+        /* Account for border and padding and rounding errors. */
+        min-width: calc(72ch + 2px + 2*var(--spacing-m) + 0.4px);
       }
       .editCommitMessage {
-        margin-top: 1em;
+        margin-top: var(--spacing-l);
+
         --gr-button: {
-          padding-left: 0;
-          padding-right: 0;
+          padding: 5px 0px;
         }
       }
       .changeStatuses,
@@ -166,7 +171,7 @@
       .relatedChanges {
         flex: 1 1 auto;
         overflow: hidden;
-        padding: 1em 0;
+        padding: var(--spacing-l) 0;
       }
       .mobile {
         display: none;
@@ -178,7 +183,7 @@
         border: 0;
         border-top: 1px solid var(--border-color);
         height: 0;
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       #commitMessage.collapsed {
         max-height: 36em;
@@ -187,7 +192,7 @@
       #relatedChanges {
       }
       #relatedChanges.collapsed {
-        margin-bottom: 1.1em;
+        margin-bottom: var(--spacing-l);
         max-height: var(--relation-chain-max-height, 2em);
         overflow: hidden;
       }
@@ -195,8 +200,8 @@
         display: flex;
         flex-direction: column;
         flex-shrink: 0;
-        margin: 1em 0;
-        padding: 0 1em;
+        margin: var(--spacing-l) 0;
+        padding: 0 var(--spacing-l);
       }
       .collapseToggleContainer {
         display: flex;
@@ -212,7 +217,7 @@
         display: block;
       }
       #relatedChangesToggle {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
         padding-top: var(--related-change-btn-top-padding, 0);
       }
       .showOnEdit {
@@ -231,12 +236,12 @@
       paper-tabs {
         background-color: var(--table-header-background-color);
         border-top: 1px solid var(--border-color);
-        height: 3rem;
+        height: calc(var(--line-height-normal) + 2*var(--spacing-m));
         --paper-tabs-selection-bar-color: var(--link-color);
       }
       paper-tab {
         box-sizing: border-box;
-        max-width: 15rem;
+        max-width: 12em;
         --paper-tab-ink: var(--link-color);
       }
       gr-thread-list,
@@ -249,14 +254,9 @@
       #uploadHelpOverlay {
         width: 50em;
       }
-      @media screen and (min-width: 80em) {
-        .commitMessage {
-          max-width: var(--commit-message-max-width, 100ch);
-        }
-      }
       #metadata {
-        --metadata-horizontal-padding: 1em;
-        padding-top: 1em;
+        --metadata-horizontal-padding: var(--spacing-l);
+        padding-top: var(--spacing-l);
         width: 100%;
       }
       /* NOTE: If you update this breakpoint, also update the
@@ -266,8 +266,7 @@
           padding: 0;
         }
         #relatedChanges {
-          border-top: 1px solid var(--border-color);
-          padding-top: 1em;
+          padding-top: var(--spacing-l);
         }
         #commitAndRelated {
           flex-direction: column;
@@ -293,14 +292,14 @@
           align-items: flex-start;
           flex-direction: column;
           flex: 1;
-          padding: .5em var(--default-horizontal-margin);
+          padding: var(--spacing-s) var(--spacing-l);
         }
         gr-change-star {
           vertical-align: middle;
         }
         .headerTitle {
           flex-wrap: wrap;
-          font-size: 1.1rem;
+          font-size: var(--font-size-h3);
         }
         .desktop {
           display: none;
@@ -322,16 +321,10 @@
         }
         .commitContainer {
           margin: 0;
-          padding: 1em;
-        }
-        .relatedChanges,
-        .changeMetadata {
-          font-size: var(--font-size-normal);
+          padding: var(--spacing-l);
         }
         .changeMetadata {
-          border-bottom: 1px solid var(--border-color);
-          border-right: none;
-          margin-top: .25em;
+          margin-top: var(--spacing-xs);
           max-width: none;
         }
         #metadata,
@@ -340,7 +333,7 @@
         }
         .commitActions {
           display: block;
-          margin-top: 1em;
+          margin-top: var(--spacing-l);
           width: 100%;
         }
         .commitMessage {
@@ -366,6 +359,7 @@
     <div
         id="mainContent"
         class="container"
+        on-show-checks-table="_handleShowTab"
         hidden$="{{_loading}}">
       <div class$="[[_computeHeaderClass(_editMode)]]">
         <div class="headerTitle">
@@ -405,7 +399,7 @@
               disable-edit="[[disableEdit]]"
               has-parent="[[hasParent]]"
               actions="[[_change.actions]]"
-              revision-actions="[[_currentRevisionActions]]"
+              revision-actions="{{_currentRevisionActions}}"
               change-num="[[_changeNum]]"
               change-status="[[_change.status]]"
               commit-num="[[_commitInfo.commit]]"
@@ -435,10 +429,6 @@
               parent-is-current="[[_parentIsCurrent]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
-          <!-- Plugins insert content into following container.
-               Stop-gap until PolyGerrit plugins interface is ready.
-               This will not work with Shadow DOM. -->
-          <div id="change_plugins"></div>
         </div>
         <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
@@ -450,7 +440,7 @@
                     hidden$="[[!_loggedIn]]"
                     primary
                     disabled="[[_replyDisabled]]"
-                    on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+                    on-click="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
               </div>
               <div
                   id="commitMessage"
@@ -467,7 +457,7 @@
                 </gr-editable-content>
                 <gr-button link
                     class="editCommitMessage"
-                    on-tap="_handleEditCommitMessage"
+                    on-click="_handleEditCommitMessage"
                     hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
                 <div class="changeId" hidden$="[[!_changeIdCommitMessageError]]">
                   <hr>
@@ -487,7 +477,7 @@
                     link
                     id="commitCollapseToggleButton"
                     class="collapseToggleButton"
-                    on-tap="_toggleCommitCollapsed">
+                    on-click="_toggleCommitCollapsed">
                   [[_computeCollapseText(_commitCollapsed)]]
                 </gr-button>
               </div>
@@ -515,7 +505,7 @@
                     link
                     id="relatedChangesToggleButton"
                     class="collapseToggleButton"
-                    on-tap="_toggleRelatedChangesCollapsed">
+                    on-click="_toggleRelatedChangesCollapsed">
                   [[_computeCollapseText(_relatedChangesCollapsed)]]
                 </gr-button>
               </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 59304aa..0c929b4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -60,11 +60,11 @@
   };
 
   const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+  const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
   const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
   Polymer({
     is: 'gr-change-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -203,7 +203,6 @@
       _loading: Boolean,
       /** @type {?} */
       _projectConfig: Object,
-      _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
@@ -226,7 +225,8 @@
       },
       _changeStatuses: {
         type: String,
-        computed: '_computeChangeStatusChips(_change, _mergeable)',
+        computed:
+          '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
       },
       _commitCollapsed: {
         type: Boolean,
@@ -247,8 +247,14 @@
         value: false,
         observer: '_updateToggleContainerClass',
       },
-      _parentIsCurrent: Boolean,
-      _submitEnabled: Boolean,
+      _parentIsCurrent: {
+        type: Boolean,
+        computed: '_isParentCurrent(_currentRevisionActions)',
+      },
+      _submitEnabled: {
+        type: Boolean,
+        computed: '_isSubmitEnabled(_currentRevisionActions)',
+      },
 
       /** @type {?} */
       _mergeable: {
@@ -281,6 +287,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
@@ -307,6 +314,7 @@
     keyboardShortcuts() {
       return {
         [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+        [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
         [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
         [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
         [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
@@ -345,7 +353,7 @@
             !== this._dynamicTabHeaderEndpoints.length) {
           console.warn('Different number of tab headers and tab content.');
         }
-      });
+      }).then(() => this._setPrimaryTab());
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
       this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
@@ -409,12 +417,31 @@
       this._showMessagesView = this.$.commentTabs.selected === 0;
     },
 
-    _handleFileTabChange() {
+    _handleFileTabChange(e) {
       const selectedIndex = this.$$('#primaryTabs').selected;
       this._showFileTabContent = selectedIndex === 0;
       // Initial tab is the static files list.
-      this._selectedFilesTabPluginEndpoint =
+      const newSelectedTab =
           this._dynamicTabContentEndpoints[selectedIndex - 1];
+      if (newSelectedTab !== this._selectedFilesTabPluginEndpoint) {
+        this._selectedFilesTabPluginEndpoint = newSelectedTab;
+
+        const tabName = this._selectedFilesTabPluginEndpoint || 'files';
+        const source = e && e.type ? e.type : '';
+        this.$.reporting.reportInteraction('tab-changed',
+            `tabname: ${tabName}, source: ${source}`);
+      }
+    },
+
+    _handleShowTab(e) {
+      const idx = this._dynamicTabContentEndpoints.indexOf(e.detail.tab);
+      if (idx === -1) {
+        console.warn(e.detail.tab + ' tab not found');
+        return;
+      }
+      this.$$('#primaryTabs').selected = idx + 1;
+      this.$$('#primaryTabs').scrollIntoView();
+      this.$.reporting.reportInteraction('show-tab', e.detail.tab);
     },
 
     _handleEditCommitMessage(e) {
@@ -451,20 +478,33 @@
       this._editingCommitMessage = false;
     },
 
-    _computeChangeStatusChips(change, mergeable) {
+    _computeChangeStatusChips(change, mergeable, submitEnabled) {
+      // Polymer 2: check for undefined
+      if ([
+        change,
+        mergeable,
+      ].some(arg => arg === undefined)) {
+        // To keep consistent with Polymer 1, we are returning undefined
+        // if not all dependencies are defined
+        return undefined;
+      }
+
       // Show no chips until mergeability is loaded.
-      if (mergeable === null || mergeable === undefined) { return []; }
+      if (mergeable === null) {
+        return [];
+      }
 
       const options = {
         includeDerived: true,
         mergeable: !!mergeable,
-        submitEnabled: this._submitEnabled,
+        submitEnabled: !!submitEnabled,
       };
       return this.changeStatuses(change, options);
     },
 
     _computeHideEditCommitMessage(loggedIn, editing, change, editMode) {
-      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED ||
+      if (!loggedIn || editing ||
+          (change && change.status === this.ChangeStatus.MERGED) ||
           editMode) {
         return true;
       }
@@ -494,6 +534,7 @@
     },
 
     _computeTotalCommentCounts(unresolvedCount, changeComments) {
+      if (!changeComments) return undefined;
       const draftCount = changeComments.computeDraftCount();
       const unresolvedString = GrCountStringFormatter.computeString(
           unresolvedCount, 'unresolved');
@@ -735,17 +776,21 @@
       });
     },
 
+    _setPrimaryTab() {
+      // Selected has to be set after the paper-tabs are visible because
+      // the selected underline depends on calculations made by the browser.
+      this.$.commentTabs.selected = 0;
+      const primaryTabs = this.$$('#primaryTabs');
+      if (primaryTabs) primaryTabs.selected = 0;
+    },
+
     _performPostLoadTasks() {
       this._maybeShowReplyDialog();
       this._maybeShowRevertDialog();
 
       this._sendShowChangeEvent();
 
-      // Selected has to be set after the paper-tabs are visible because
-      // the selected underline depends on calculations made by the browser.
-      this.$.commentTabs.selected = 0;
-      const primaryTabs = this.$$('#primaryTabs');
-      if (primaryTabs) primaryTabs.selected = 0;
+      this._setPrimaryTab();
 
       this.async(() => {
         if (this.viewState.scrollTop) {
@@ -758,7 +803,12 @@
       });
     },
 
-    _paramsAndChangeChanged(value) {
+    _paramsAndChangeChanged(value, change) {
+      // Polymer 2: check for undefined
+      if ([value, change].some(arg => arg === undefined)) {
+        return;
+      }
+
       // If the change number or patch range is different, then reset the
       // selected file index.
       const patchRangeState = this.viewState.patchRange;
@@ -813,7 +863,8 @@
       Gerrit.awaitPluginsLoaded()
           .then(this._getLoggedIn.bind(this))
           .then(loggedIn => {
-            if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+            if (!loggedIn || !this._change ||
+                this._change.status !== this.ChangeStatus.MERGED) {
             // Do not display dialog if not logged-in or the change is not
             // merged.
               return;
@@ -855,19 +906,22 @@
     _changeChanged(change) {
       if (!change || !this._patchRange || !this._allPatchSets) { return; }
 
+      // We get the parent first so we keep the original value for basePatchNum
+      // and not the updated value.
       const parent = this._getBasePatchNum(change, this._patchRange);
 
-      this.set('_patchRange.basePatchNum', parent);
       this.set('_patchRange.patchNum', this._patchRange.patchNum ||
               this.computeLatestPatchNum(this._allPatchSets));
 
+      this.set('_patchRange.basePatchNum', parent);
+
       const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title});
     },
 
     /**
      * Gets base patch number, if it is a parent try and decide from
-     * preference weather to default to `auto merge`, `Parent 1` or `PARENT`.
+     * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
      *
      * @param {Object} change
      * @param {Object} patchRange
@@ -898,8 +952,8 @@
       return 'PARENT';
     },
 
-    _computeShowPrimaryTabs(dynamicTabContentEndpoints) {
-      return dynamicTabContentEndpoints.length > 0;
+    _computeShowPrimaryTabs(dynamicTabHeaderEndpoints) {
+      return dynamicTabHeaderEndpoints && dynamicTabHeaderEndpoints.length > 0;
     },
 
     _computeChangeUrl(change) {
@@ -932,6 +986,11 @@
     },
 
     _computeChangeIdCommitMessageError(commitMessage, change) {
+      // Polymer 2: check for undefined
+      if ([commitMessage, change].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
 
       // Find the last match in the commit message:
@@ -986,6 +1045,11 @@
     },
 
     _computeReplyButtonLabel(changeRecord, canStartReview) {
+      // Polymer 2: check for undefined
+      if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
+        return 'Reply';
+      }
+
       if (canStartReview) {
         return 'Start review';
       }
@@ -1151,6 +1215,7 @@
     },
 
     _getProjectConfig() {
+      if (!this._change) return;
       return this.$.restAPI.getProjectConfig(this._change.project).then(
           config => {
             this._projectConfig = config;
@@ -1161,18 +1226,6 @@
       return this.$.restAPI.getPreferences();
     },
 
-    _updateRebaseAction(revisionActions) {
-      if (revisionActions && revisionActions.rebase) {
-        revisionActions.rebase.rebaseOnCurrent =
-            !!revisionActions.rebase.enabled;
-        this._parentIsCurrent = !revisionActions.rebase.enabled;
-        revisionActions.rebase.enabled = true;
-      } else {
-        this._parentIsCurrent = true;
-      }
-      return revisionActions;
-    },
-
     _prepareCommitMsgForLinkify(msg) {
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       // This is a zero-with space. It is added to prevent the linkify library
@@ -1200,7 +1253,7 @@
       if (!this._patchRange.patchNum &&
           change.current_revision === edit.base_revision) {
         change.current_revision = edit.commit.commit;
-        this._patchRange.patchNum = this.EDIT_NAME;
+        this.set('_patchRange.patchNum', this.EDIT_NAME);
         // Because edits are fibbed as revisions and added to the revisions
         // array, and revision actions are always derived from the 'latest'
         // patch set, we must copy over actions from the patch set base.
@@ -1238,8 +1291,6 @@
               this._latestCommitMessage = null;
             }
 
-            // Update the submit enabled based on current revision.
-            this._submitEnabled = this._isSubmitEnabled(currentRevision);
 
             const lineHeight = getComputedStyle(this).lineHeight;
 
@@ -1256,8 +1307,6 @@
                 currentRevision.commit.commit = latestRevisionSha;
               }
               this._commitInfo = currentRevision.commit;
-              this._currentRevisionActions =
-                      this._updateRebaseAction(currentRevision.actions);
               this._selectedRevision = currentRevision;
               // TODO: Fetch and process files.
             } else {
@@ -1269,9 +1318,17 @@
           });
     },
 
-    _isSubmitEnabled(currentRevision) {
-      return !!(currentRevision.actions && currentRevision.actions.submit &&
-          currentRevision.actions.submit.enabled);
+    _isSubmitEnabled(revisionActions) {
+      return !!(revisionActions && revisionActions.submit &&
+        revisionActions.submit.enabled);
+    },
+
+    _isParentCurrent(revisionActions) {
+      if (revisionActions && revisionActions.rebase) {
+        return !revisionActions.rebase.enabled;
+      } else {
+        return true;
+      }
     },
 
     _getEdit() {
@@ -1281,6 +1338,7 @@
     _getLatestCommitMessage() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
           this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
+        if (!commitInfo) return Promise.resolve();
         this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
       });
@@ -1348,15 +1406,17 @@
     /**
      * Reload the change.
      *
-     * @param {boolean=} opt_reloadRelatedChanges Reloads the related chanegs
-     *     when true.
+     * @param {boolean=} opt_isLocationChange Reloads the related changes
+     *     when true and ends reporting events that started on location change.
      * @return {Promise} A promise that resolves when the core data has loaded.
      *     Some non-core data loading may still be in-flight when the core data
      *     promise resolves.
      */
-    _reload(opt_reloadRelatedChanges) {
+    _reload(opt_isLocationChange) {
       this._loading = true;
       this._relatedChangesCollapsed = true;
+      this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+      this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
 
       // Array to house all promises related to data requests.
       const allDataPromises = [];
@@ -1369,7 +1429,13 @@
       // Resolves when the loading flag is set to false, meaning that some
       // change content may start appearing.
       const loadingFlagSet = detailCompletes
-          .then(() => { this._loading = false; });
+          .then(() => { this._loading = false; })
+          .then(() => {
+            this.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+            if (opt_isLocationChange) {
+              this.$.reporting.changeDisplayed();
+            }
+          });
 
       // Resolves when the project config has loaded.
       const projectConfigLoaded = detailCompletes
@@ -1429,20 +1495,20 @@
         coreDataPromise = mergeabilityLoaded;
       }
 
-      if (opt_reloadRelatedChanges) {
+      if (opt_isLocationChange) {
         const relatedChangesLoaded = coreDataPromise
             .then(() => this.$.relatedChanges.reload());
         allDataPromises.push(relatedChangesLoaded);
       }
 
-      this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
       Promise.all(allDataPromises).then(() => {
         this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
-        this.$.reporting.changeFullyLoaded();
+        if (opt_isLocationChange) {
+          this.$.reporting.changeFullyLoaded();
+        }
       });
 
-      return coreDataPromise
-          .then(() => { this.$.reporting.changeDisplayed(); });
+      return coreDataPromise;
     },
 
     /**
@@ -1457,6 +1523,10 @@
     },
 
     _getMergeability() {
+      if (!this._change) {
+        this._mergeable = null;
+        return Promise.resolve();
+      }
       // If the change is closed, it is not mergeable. Note: already merged
       // changes are obviously not mergeable, but the mergeability API will not
       // answer for abandoned changes.
@@ -1594,8 +1664,7 @@
     _computeShowRelatedToggle() {
       // Make sure the max height has been applied, since there is now content
       // to populate.
-      // TODO update to polymer 2.x syntax
-      if (!this.getComputedStyleValue('--relation-chain-max-height')) {
+      if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
         this._updateRelatedChangeMaxHeight();
       }
       // Prevents showMore from showing when click on related change, since the
@@ -1689,6 +1758,10 @@
     },
 
     _computeEditMode(patchRangeRecord, paramsRecord) {
+      if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (paramsRecord.base && paramsRecord.base.edit) { return true; }
 
       const patchRange = patchRangeRecord.base || {};
@@ -1774,7 +1847,7 @@
     },
 
     _computeCurrentRevision(currentRevision, revisions) {
-      return revisions && revisions[currentRevision];
+      return currentRevision && revisions && revisions[currentRevision];
     },
 
     _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index f74160b..e00bab5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="../../edit/gr-edit-constants.html">
 <link rel="import" href="gr-change-view.html">
@@ -79,7 +81,7 @@
       });
       element = fixture('basic');
       sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
-      Gerrit._setPluginsCount(0);
+      Gerrit._loadPlugins([]);
     });
 
     teardown(done => {
@@ -90,9 +92,7 @@
     });
 
     getCustomCssValue = cssParam => {
-      // TODO: Update to be compatible with 2.x when we upgrade from
-      // 1.x to 2.x.
-      return element.getComputedStyleValue(cssParam);
+      return util.getComputedStyleValue(cssParam, element);
     };
 
     test('_handleMessageAnchorTap', () => {
@@ -345,8 +345,10 @@
         ],
       };
       setup(() => {
+        // Fake computeDraftCount as its required for ChangeComments,
+        // see gr-comment-api#reloadDrafts.
         reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
-            .returns(Promise.resolve({drafts}));
+            .returns(Promise.resolve({drafts, computeDraftCount: () => 1}));
       });
 
       test('drafts are reloaded when reload-drafts fired', done => {
@@ -530,65 +532,11 @@
       assert.equal(result, 'CC=\u200Btest@google.com');
     }),
 
-    test('_updateRebaseAction', () => {
-      const currentRevisionActions = {
-        cherrypick: {
-          enabled: true,
-          label: 'Cherry Pick',
-          method: 'POST',
-          title: 'cherrypick',
-        },
-        rebase: {
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        },
-      };
-      element._parentIsCurrent = undefined;
-
-      // Rebase enabled should always end up true.
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.equal(element._updateRebaseAction(currentRevisionActions),
-          currentRevisionActions);
-
-      assert.isTrue(currentRevisionActions.rebase.enabled);
-      assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
-      assert.isFalse(element._parentIsCurrent);
-
-      delete currentRevisionActions.rebase.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.equal(element._updateRebaseAction(currentRevisionActions),
-          currentRevisionActions);
-
-      assert.isTrue(currentRevisionActions.rebase.enabled);
-      assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
-      assert.isTrue(element._parentIsCurrent);
-    });
-
     test('_isSubmitEnabled', () => {
       assert.isFalse(element._isSubmitEnabled({}));
-      assert.isFalse(element._isSubmitEnabled({actions: {}}));
-      assert.isFalse(element._isSubmitEnabled({actions: {submit: {}}}));
+      assert.isFalse(element._isSubmitEnabled({submit: {}}));
       assert.isTrue(element._isSubmitEnabled(
-          {actions: {submit: {enabled: true}}}));
-    });
-
-    test('_updateRebaseAction sets _parentIsCurrent on no rebase', () => {
-      const currentRevisionActions = {
-        cherrypick: {
-          enabled: true,
-          label: 'Cherry Pick',
-          method: 'POST',
-          title: 'cherrypick',
-        },
-      };
-      element._parentIsCurrent = undefined;
-      element._updateRebaseAction(currentRevisionActions);
-      assert.isTrue(element._parentIsCurrent);
+          {submit: {enabled: true}}));
     });
 
     test('_reload is called when an approved label is removed', () => {
@@ -1599,32 +1547,44 @@
       sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
 
       // Delete
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.DELETE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.DELETE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openDeleteDialog.called);
       assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
 
       // Restore
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RESTORE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RESTORE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRestoreDialog.called);
       assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
 
       // Rename
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RENAME.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RENAME.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRenameDialog.called);
       assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
 
       // Open
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.OPEN.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.OPEN.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
@@ -1830,5 +1790,50 @@
       MockInteractions.tap(element.$.changeStar.$$('button'));
       assert.isTrue(stub.called);
     });
+
+    suite('gr-reporting tests', () => {
+      setup(() => {
+        element._patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: 1,
+        };
+        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
+        sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
+        sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
+        sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
+        sandbox.stub(element, '_getLatestCommitMessage')
+            .returns(Promise.resolve());
+      });
+
+      test('don\'t report changedDisplayed on reply', done => {
+        const changeDisplayStub =
+          sandbox.stub(element.$.reporting, 'changeDisplayed');
+        const changeFullyLoadedStub =
+          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+        element._handleReplySent();
+        flush(() => {
+          assert.isFalse(changeDisplayStub.called);
+          assert.isFalse(changeFullyLoadedStub.called);
+          done();
+        });
+      });
+
+      test('report changedDisplayed on _paramsChanged', done => {
+        const changeDisplayStub =
+          sandbox.stub(element.$.reporting, 'changeDisplayed');
+        const changeFullyLoadedStub =
+          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+        element._paramsChanged({
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: 101,
+          project: 'test-project',
+        });
+        flush(() => {
+          assert.isTrue(changeDisplayStub.called);
+          assert.isTrue(changeFullyLoadedStub.called);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index e4183df..a7e65a3 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -43,10 +43,10 @@
       }
       .container {
         display: flex;
-        margin: .5em 0;
+        margin: var(--spacing-m) 0;
       }
       .lineNum {
-        margin-right: .5em;
+        margin-right: var(--spacing-m);
         min-width: 10em;
         text-align: right;
       }
@@ -57,7 +57,7 @@
       @media screen and (max-width: 50em) {
         .container {
           flex-direction: column;
-          margin: 0 0 .5em .5em;
+          margin: 0 0 var(--spacing-m) var(--spacing-m);
         }
         .lineNum {
           min-width: initial;
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index b19ca62..42cb976 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -18,7 +18,6 @@
   'use strict';
   Polymer({
     is: 'gr-comment-list',
-    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 9996abc..c18ae8d 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-comment-list.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
index b6c8fcc..902bf41 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index ddab319..e2fcdff 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-commit-info',
-    _legacyUndefinedCheck: true,
 
     properties: {
       change: Object,
@@ -47,11 +46,21 @@
     },
 
     _computeShowWebLink(change, commitInfo, serverConfig) {
+      // Polymer 2: check for undefined
+      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const weblink = this._getWeblink(change, commitInfo, serverConfig);
       return !!weblink && !!weblink.url;
     },
 
     _computeWebLink(change, commitInfo, serverConfig) {
+      // Polymer 2: check for undefined
+      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
       return url;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index 438a3f3..f271a70 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-commit-info</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-commit-info.html">
@@ -119,6 +121,7 @@
           },
         ],
       };
+      element.serverConfig = {};
 
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index 8803eb3..05a2bb2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<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-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -42,6 +43,8 @@
       }
       iron-autogrow-textarea {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         padding: 0;
         width: 73ch; /* Add a char to account for the border. */
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index a371e13..524876e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-confirm-abandon-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -38,6 +37,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -55,6 +55,7 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this._confirm();
     },
 
@@ -64,6 +65,7 @@
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
index 95d5374..cc4b80e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-abandon-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-abandon-dialog.html">
 
@@ -49,19 +51,26 @@
     test('_handleConfirmTap', () => {
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
-      sandbox.stub(element, '_confirm');
+      sandbox.spy(element, '_handleConfirmTap');
+      sandbox.spy(element, '_confirm');
       element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
+      assert.isTrue(confirmHandler.calledOnce);
+      assert.isTrue(element._handleConfirmTap.called);
       assert.isTrue(element._confirm.called);
+      assert.isTrue(element._confirm.called);
+      assert.isTrue(element._confirm.calledOnce);
     });
 
     test('_handleCancelTap', () => {
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
-      sandbox.stub(element, '_handleCancelTap');
+      sandbox.spy(element, '_handleCancelTap');
       element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
+      assert.isTrue(cancelHandler.calledOnce);
       assert.isTrue(element._handleCancelTap.called);
+      assert.isTrue(element._handleCancelTap.calledOnce);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
index cd196ec..b9e9155 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.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-dialog/gr-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
index 2e8af0e..a0da331 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
@@ -19,7 +19,10 @@
 
   Polymer({
     is: 'gr-confirm-cherrypick-conflict-dialog',
-    _legacyUndefinedCheck: true,
+
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
 
     /**
      * Fired when the confirm button is pressed.
@@ -35,11 +38,13 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
index 77b102c..f411de4 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-conflict-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-cherrypick-conflict-dialog.html">
 
@@ -47,19 +49,23 @@
     test('_handleConfirmTap', () => {
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
-      sandbox.stub(element, '_handleConfirmTap');
+      sandbox.spy(element, '_handleConfirmTap');
       element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
+      assert.isTrue(confirmHandler.calledOnce);
       assert.isTrue(element._handleConfirmTap.called);
+      assert.isTrue(element._handleConfirmTap.calledOnce);
     });
 
     test('_handleCancelTap', () => {
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
-      sandbox.stub(element, '_handleCancelTap');
+      sandbox.spy(element, '_handleCancelTap');
       element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
+      assert.isTrue(cancelHandler.calledOnce);
       assert.isTrue(element._handleCancelTap.called);
+      assert.isTrue(element._handleCancelTap.calledOnce);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index 84558ac..8ddcd83 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -15,8 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.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-dialog/gr-dialog.html">
@@ -43,16 +45,16 @@
       .main label,
       .main input[type="text"] {
         display: block;
-        font: inherit;
         width: 100%;
       }
       .main .message {
-        border: groove;
         width: 100%;
       }
       iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         padding: 0;
-
         --iron-autogrow-textarea: {
           font-family: var(--monospace-font-family);
           width: 72ch;
@@ -77,12 +79,17 @@
         <label for="baseInput">
           Provide base commit sha1 for cherry-pick
         </label>
-        <input
-            is="iron-input"
-            id="baseCommitInput"
+        <iron-input
             maxlength="40"
             placeholder="(optional)"
             bind-value="{{baseCommit}}">
+          <input
+              is="iron-input"
+              id="baseCommitInput"
+              maxlength="40"
+              placeholder="(optional)"
+              bind-value="{{baseCommit}}">
+        </iron-input>
         <label for="messageInput">
           Cherry Pick Commit Message
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index bdc0bb2..5278540 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-confirm-cherrypick-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -51,11 +50,24 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_computeMessage(changeStatus, commitNum, commitMessage)',
     ],
 
     _computeMessage(changeStatus, commitNum, commitMessage) {
+      // Polymer 2: check for undefined
+      if ([
+        changeStatus,
+        commitNum,
+        commitMessage,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       let newMessage = commitMessage;
 
       if (changeStatus === 'MERGED') {
@@ -66,11 +78,13 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 5c51fe0..22a2aba 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-cherrypick-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index 621ef0a..24c1132 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.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-dialog/gr-dialog.html">
@@ -47,11 +48,9 @@
       .main label,
       .main input[type="text"] {
         display: block;
-        font: inherit;
         width: 100%;
       }
       .main .message {
-        border: groove;
         width: 100%;
       }
       .warning {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index 91600ff..c6b0adf 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-confirm-move-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -47,13 +46,19 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
index e619425..8d6e029 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-move-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-move-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index 912bbfa6a..cf2721a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -41,14 +41,13 @@
       .parentRevisionContainer label,
       .parentRevisionContainer input[type="text"] {
         display: block;
-        font: inherit;
         width: 100%;
       }
       .parentRevisionContainer label {
-        margin-bottom: .2em;
+        margin-bottom: var(--spacing-xs);
       }
       .rebaseOption {
-        margin: .5em 0;
+        margin: var(--spacing-m) 0;
       }
     </style>
     <gr-dialog
@@ -63,7 +62,7 @@
           <input id="rebaseOnParentInput"
               name="rebaseOptions"
               type="radio"
-              on-tap="_handleRebaseOnParent">
+              on-click="_handleRebaseOnParent">
           <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
             Rebase on parent change
           </label>
@@ -78,7 +77,7 @@
               name="rebaseOptions"
               type="radio"
               disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-              on-tap="_handleRebaseOnTip">
+              on-click="_handleRebaseOnTip">
           <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
             Rebase on top of the [[branch]]
             branch<span hidden$="[[!hasParent]]">
@@ -94,7 +93,7 @@
           <input id="rebaseOnOtherInput"
               name="rebaseOptions"
               type="radio"
-              on-tap="_handleRebaseOnOther">
+              on-click="_handleRebaseOnOther">
           <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
             Rebase on a specific change, ref, or commit <span hidden$="[[!hasParent]]">
               (breaks relation chain)
@@ -107,7 +106,7 @@
               query="[[_query]]"
               no-debounce
               text="{{_text}}"
-              on-tap="_handleEnterChangeNumberTap"
+              on-click="_handleEnterChangeNumberClick"
               allow-non-suggested-values
               placeholder="Change number, ref, or commit hash">
           </gr-autocomplete>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 77bc798..54ce271 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-confirm-rebase-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -120,6 +119,7 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(new CustomEvent('confirm',
           {detail: {base: this._getSelectedBase()}}));
       this._text = '';
@@ -127,6 +127,7 @@
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(new CustomEvent('cancel'));
       this._text = '';
     },
@@ -135,7 +136,7 @@
       this.$.parentInput.focus();
     },
 
-    _handleEnterChangeNumberTap() {
+    _handleEnterChangeNumberClick() {
       this.$.rebaseOnOtherInput.checked = true;
     },
 
@@ -144,6 +145,11 @@
      * the corresponding value to be submitted.
      */
     _updateSelectedOption(rebaseOnCurrent, hasParent) {
+      // Polymer 2: check for undefined
+      if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
         this.$.rebaseOnParentInput.checked = true;
       } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index c6e9ec4..cd5b130 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-rebase-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-rebase-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 9e5f1de..2e1e6ae 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<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-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 
 <dom-module id="gr-confirm-revert-dialog">
   <template>
@@ -37,6 +39,8 @@
       }
       iron-autogrow-textarea {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         padding: 0;
         width: 73ch; /* Add a char to account for the border. */
 
@@ -53,15 +57,17 @@
         on-cancel="_handleCancelTap">
       <div class="header" slot="header">Revert Merged Change</div>
       <div class="main" slot="main">
-        <label for="messageInput">
-          Revert Commit Message
-        </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <gr-endpoint-decorator name="confirm-revert-change">
+          <label for="messageInput">
+            Revert Commit Message
+          </label>
+          <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+              max-rows="15"
+              bind-value="{{message}}"></iron-autogrow-textarea>
+        </gr-endpoint-decorator>
       </div>
     </gr-dialog>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 93e21c7..662b64c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-confirm-revert-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -40,6 +39,10 @@
       message: String,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     populateRevertMessage(message, commitHash) {
       // Figure out what the revert title should be.
       const originalTitle = message.split('\n')[0];
@@ -56,11 +59,13 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index c5a1bde..6e41555 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-confirm-revert-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
index 1036b7f..1a1276d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
@@ -15,11 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-submit-dialog">
@@ -29,7 +31,7 @@
         min-width: 40em;
       }
       p {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       @media screen and (max-width: 50em) {
         #dialog {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
index 93d38df..e86e21c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-confirm-submit-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -56,11 +55,13 @@
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
index 86c15f6..40fa29a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-submit-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-confirm-submit-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 28d25d2..e20bbd7 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -31,7 +32,7 @@
       }
       section {
         display: flex;
-        padding: .5em 1.5em;
+        padding: var(--spacing-m) var(--spacing-xl);
       }
       section:not(:first-of-type) {
         border-top: 1px solid var(--border-color);
@@ -39,7 +40,7 @@
       .flexContainer {
         display: flex;
         justify-content: space-between;
-        padding-top: .75em;
+        padding-top: var(--spacing-m);
       }
       .footer {
         justify-content: flex-end;
@@ -52,15 +53,15 @@
       }
       .patchFiles,
       .archivesContainer {
-        padding-bottom: .5em;
+        padding-bottom: var(--spacing-m);
       }
       .patchFiles {
-        margin-right: 2em;
+        margin-right: var(--spacing-xxl);
       }
       .patchFiles a,
       .archives a {
         display: inline-block;
-        margin-right: 1em;
+        margin-right: var(--spacing-l);
       }
       .patchFiles a:last-of-type,
       .archives a:last-of-type {
@@ -120,7 +121,7 @@
       <span class="closeButtonContainer">
         <gr-button id="closeButton"
             link
-            on-tap="_handleCloseTap">Close</gr-button>
+            on-click="_handleCloseTap">Close</gr-button>
       </span>
     </section>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index b6c155e..b297a14 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-download-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -48,6 +47,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
@@ -70,16 +70,17 @@
 
     _computeDownloadCommands(change, patchNum, _selectedScheme) {
       let commandObj;
+      if (!change) return [];
       for (const rev of Object.values(change.revisions || {})) {
         if (this.patchNumEquals(rev._number, patchNum) &&
-            rev.fetch.hasOwnProperty(_selectedScheme)) {
+            rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
           commandObj = rev.fetch[_selectedScheme].commands;
           break;
         }
       }
       const commands = [];
       for (const title in commandObj) {
-        if (!commandObj.hasOwnProperty(title)) { continue; }
+        if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
         commands.push({
           title,
           command: commandObj[title],
@@ -116,6 +117,10 @@
      * @return {string} Not sure why there was a mismatch
      */
     _computeDownloadLink(change, patchNum, opt_zip) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum].some(arg => arg === undefined)) {
+        return '';
+      }
       return this.changeBaseURL(change.project, change._number, patchNum) +
           '/patch?' + (opt_zip ? 'zip' : 'download');
     },
@@ -129,6 +134,11 @@
      * @return {string}
      */
     _computeDownloadFilename(change, patchNum, opt_zip) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum].some(arg => arg === undefined)) {
+        return '';
+      }
+
       let shortRev = '';
       for (const rev in change.revisions) {
         if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
@@ -140,6 +150,10 @@
     },
 
     _computeHidePatchFile(change, patchNum) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum].some(arg => arg === undefined)) {
+        return false;
+      }
       for (const rev of Object.values(change.revisions || {})) {
         if (this.patchNumEquals(rev._number, patchNum)) {
           const parentLength = rev.commit && rev.commit.parents ?
@@ -151,11 +165,20 @@
     },
 
     _computeArchiveDownloadLink(change, patchNum, format) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum, format].some(arg => arg === undefined)) {
+        return '';
+      }
       return this.changeBaseURL(change.project, change._number, patchNum) +
           '/archive?format=' + format;
     },
 
     _computeSchemes(change, patchNum) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum].some(arg => arg === undefined)) {
+        return [];
+      }
+
       for (const rev of Object.values(change.revisions || {})) {
         if (this.patchNumEquals(rev._number, patchNum)) {
           const fetch = rev.fetch;
@@ -175,6 +198,7 @@
 
     _handleCloseTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('close', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 214363b..82574808 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-download-dialog.html">
 
@@ -144,7 +146,8 @@
     });
 
     test('anchors use download attribute', () => {
-      const anchors = Polymer.dom(element.root).querySelectorAll('a');
+      const anchors = Array.from(
+          Polymer.dom(element.root).querySelectorAll('a'));
       assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index f7d90eb..9146125 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -29,6 +30,7 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../gr-file-list-constants.html">
+<link rel="import" href="../gr-commit-info/gr-commit-info.html">
 
 <dom-module id="gr-file-list-header">
   <template>
@@ -47,7 +49,7 @@
         background-color: var(--table-header-background-color);
         border-top: 1px solid var(--border-color);
         display: flex;
-        padding: 6px var(--default-horizontal-margin);
+        padding: var(--spacing-s) var(--spacing-l);
       }
       .patchInfo-left {
         align-items: baseline;
@@ -143,7 +145,7 @@
         margin-right: -5px;
       }
       .fileViewActionsLabel {
-        margin-right: .2rem;
+        margin-right: var(--spacing-xs);
       }
       @media screen and (max-width: 50em) {
         .patchInfo-header .desktop {
@@ -216,28 +218,28 @@
         <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
           <gr-button link
               class="upload"
-              on-tap="_handleUploadTap">Update Change</gr-button>
+              on-click="_handleUploadTap">Update Change</gr-button>
         </span>
         <span class="downloadContainer desktop">
           <gr-button link
               class="download"
-              on-tap="_handleDownloadTap">Download</gr-button>
+              on-click="_handleDownloadTap">Download</gr-button>
         </span>
         <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
           <gr-button link
               class="includedIn"
-              on-tap="_handleIncludedInTap">Included In</gr-button>
+              on-click="_handleIncludedInTap">Included In</gr-button>
         </span>
         <template is="dom-if"
             if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
           <gr-button
               id="expandBtn"
               link
-              on-tap="_expandAllDiffs">Expand All</gr-button>
+              on-click="_expandAllDiffs">Expand All</gr-button>
           <gr-button
               id="collapseBtn"
               link
-              on-tap="_collapseAllDiffs">Collapse All</gr-button>
+              on-click="_collapseAllDiffs">Collapse All</gr-button>
         </template>
         <template is="dom-if"
             if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
@@ -261,7 +263,7 @@
                 has-tooltip
                 title="Diff preferences"
                 class="prefsButton desktop"
-                on-tap="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
+                on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
           </span>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 8321879..d6fbfc4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -23,7 +23,6 @@
 
   Polymer({
     is: 'gr-file-list-header',
-    _legacyUndefinedCheck: true,
 
     /**
      * @event expand-diffs
@@ -94,6 +93,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
     ],
 
@@ -132,10 +132,20 @@
     },
 
     _computeDescriptionReadOnly(loggedIn, change, account) {
+      // Polymer 2: check for undefined
+      if ([loggedIn, change, account].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return !(loggedIn && (account._account_id === change.owner._account_id));
     },
 
     _computePatchSetDescription(change, patchNum) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum].some(arg => arg === undefined)) {
+        return;
+      }
+
       const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
       this._patchsetDescription = (rev && rev.description) ?
         rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
@@ -217,6 +227,7 @@
 
     _handleDownloadTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(
           new CustomEvent('open-download-dialog', {bubbles: false}));
     },
@@ -239,6 +250,7 @@
 
     _handleUploadTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.dispatchEvent(
           new CustomEvent('open-upload-help-dialog', {bubbles: false}));
     },
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index adfeeb4..ac626ab 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list-header</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-file-list-header.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index f9ee54f..649aa53 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -15,9 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/async-foreach-behavior/async-foreach-behavior.html">
 <link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -47,8 +48,8 @@
         align-items: center;
         border-top: 1px solid var(--border-color);
         display: flex;
-        min-height: 2.25em;
-        padding: .2em var(--default-horizontal-margin) .2em calc(var(--default-horizontal-margin) - .35rem);
+        min-height: calc(var(--line-height-normal) + 2*var(--spacing-s));
+        padding: var(--spacing-xs) var(--spacing-l) var(--spacing-xs) calc(var(--spacing-l) - .35rem);
       }
       :host(.loading) .row {
         opacity: .5;
@@ -115,16 +116,13 @@
         cursor: pointer;
         flex: 1;
         text-decoration: none;
-        white-space: nowrap;
+        /* Wrap it into multiple lines if too long. */
+        white-space: normal;
+        word-break: break-word;
       }
       .path:hover :first-child {
         text-decoration: underline;
       }
-      .path,
-      .path div {
-        overflow: hidden;
-        text-overflow: ellipsis;
-      }
       .oldPath {
         color: var(--deemphasized-text-color);
       }
@@ -137,16 +135,18 @@
         min-width: 7.5em;
       }
       .comments {
-        padding-left: 2em;
-        min-width: 20em;
+        padding-left: var(--spacing-l);
+        min-width: 7.5em;
       }
       .row:not(.header-row) .stats,
       .total-stats {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         display: flex;
       }
       .sizeBars {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
         min-width: 7em;
         text-align: center;
       }
@@ -165,18 +165,18 @@
         color: var(--vote-text-color-disliked);
         text-align: left;
         min-width: 4em;
-        padding-left: 0.5em;
+        padding-left: var(--spacing-s);
       }
       .drafts {
         color: #C62828;
         font-weight: var(--font-weight-bold);
       }
       .show-hide {
-        margin-left: .35em;
+        margin-left: var(--spacing-s);
         width: 1.9em;
       }
       .fileListButton {
-        margin: .5em;
+        margin: var(--spacing-m);
       }
       .totalChanges {
         justify-content: flex-end;
@@ -200,15 +200,11 @@
       .truncatedFileName {
         display: none;
       }
-      .expanded .fullFileName {
-        white-space: normal;
-        word-wrap: break-word;
-      }
       .mobile {
         display: none;
       }
       .reviewed {
-        margin-left: 2em;
+        margin-left: var(--spacing-xxl);
         width: 15em;
       }
       .reviewed label {
@@ -234,7 +230,7 @@
       }
       .reviewedLabel {
         color: var(--deemphasized-text-color);
-        margin-right: 1em;
+        margin-right: var(--spacing-l);
         opacity: 0;
       }
       .reviewedLabel.isReviewed {
@@ -247,10 +243,12 @@
       .markReviewed,
       .pathLink {
         display: inline-block;
-        margin: -.2em 0;
-        padding: .4em 0;
+        margin: -2px 0;
+        padding: var(--spacing-s) 0;
       }
-      @media screen and (max-width: 50em) {
+
+      /** small screen breakpoint: 768px */
+      @media screen and (max-width: 55em) {
         .desktop {
           display: none;
         }
@@ -285,7 +283,7 @@
     </style>
     <div
         id="container"
-        on-tap="_handleFileListTap">
+        on-click="_handleFileListClick">
       <div class="header-row row">
         <div class="status"></div>
         <div class="path">File</div>
@@ -498,7 +496,7 @@
       <gr-button
           class="fileListButton"
           id="incrementButton"
-          link on-tap="_incrementNumFilesShown">
+          link on-click="_incrementNumFilesShown">
         [[_computeIncrementText(numFilesShown, _files)]]
       </gr-button>
       <gr-tooltip-content
@@ -508,7 +506,7 @@
         <gr-button
             class="fileListButton"
             id="showAllButton"
-            link on-tap="_showAllFiles">
+            link on-click="_showAllFiles">
           [[_computeShowAllText(_files)]]
         </gr-button><!--
   --></gr-tooltip-content>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 8dba99d..8d9b905 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -41,29 +41,8 @@
     U: 'Unchanged',
   };
 
-  const Defs = {};
-
-  /**
-   * Object containing layout values to be used in rendering size-bars.
-   * `max{Inserted,Deleted}` represent the largest values of the
-   * `lines_inserted` and `lines_deleted` fields of the files respectively. The
-   * `max{Addition,Deletion}Width` represent the width of the graphic allocated
-   * to the insertion or deletion side respectively. Finally, the
-   * `deletionOffset` value represents the x-position for the deletion bar.
-   *
-   * @typedef {{
-   *    maxInserted: number,
-   *    maxDeleted: number,
-   *    maxAdditionWidth: number,
-   *    maxDeletionWidth: number,
-   *    deletionOffset: number,
-   * }}
-   */
-  Defs.LayoutStats;
-
   Polymer({
     is: 'gr-file-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a draft refresh should get triggered
@@ -148,7 +127,7 @@
 
       _shownFiles: {
         type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files.*)',
+        computed: '_computeFilesShown(numFilesShown, _files)',
       },
 
       /**
@@ -166,7 +145,7 @@
         type: Boolean,
         observer: '_loadingChanged',
       },
-      /** @type {Defs.LayoutStats|undefined} */
+      /** @type {Gerrit.LayoutStats|undefined} */
       _sizeBarLayout: {
         type: Object,
         computed: '_computeSizeBarLayout(_shownFiles.*)',
@@ -203,6 +182,7 @@
     behaviors: [
       Gerrit.AsyncForeachBehavior,
       Gerrit.DomUtilBehavior,
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.PathListBehavior,
@@ -332,7 +312,6 @@
     },
 
     get diffs() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-diff-host'));
     },
@@ -512,7 +491,7 @@
 
       this.set(['_files', index, 'isReviewed'], reviewed);
       if (index < this._shownFiles.length) {
-        this.set(['_shownFiles', index, 'isReviewed'], reviewed);
+        this.notifyPath(`_shownFiles.${index}.isReviewed`);
       }
 
       this._saveReviewedState(path, reviewed);
@@ -562,7 +541,7 @@
      * Handle all events from the file list dom-repeat so event handleers don't
      * have to get registered for potentially very long lists.
      */
-    _handleFileListTap(e) {
+    _handleFileListClick(e) {
       // Traverse upwards to find the row element if the target is not the row.
       let row = e.target;
       while (!row.classList.contains('row') && row.parentElement) {
@@ -800,6 +779,11 @@
     },
 
     _computeDiffURL(change, patchNum, basePatchNum, path, editMode) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum, basePatchNum, path, editMode]
+          .some(arg => arg === undefined)) {
+        return;
+      }
       // TODO(kaspern): Fix editing for commit messages and merge lists.
       if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
           path !== this.MERGE_LIST_PATH) {
@@ -860,8 +844,19 @@
     },
 
     _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
+      // Polymer 2: check for undefined
+      if ([
+        filesByPath,
+        changeComments,
+        patchRange,
+        reviewed,
+        loading,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       // Await all promises resolving from reload. @See Issue 9057
-      if (loading) { return; }
+      if (loading || !changeComments) { return; }
 
       const commentedPaths = changeComments.getPaths(patchRange);
       const files = Object.assign({}, filesByPath);
@@ -879,10 +874,15 @@
     },
 
     _computeFilesShown(numFilesShown, files) {
+      // Polymer 2: check for undefined
+      if ([numFilesShown, files].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const previousNumFilesShown = this._shownFiles ?
         this._shownFiles.length : 0;
 
-      const filesShown = files.base.slice(0, numFilesShown);
+      const filesShown = files.slice(0, numFilesShown);
       this.fire('files-shown-changed', {length: filesShown.length});
 
       // Start the timer for the rendering work hwere because this is where the
@@ -904,12 +904,13 @@
     },
 
     _filesChanged() {
-      Polymer.dom.flush();
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
-      const files = Array.from(
-          Polymer.dom(this.root).querySelectorAll('.file-row'));
-      this.$.fileCursor.stops = files;
-      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+      if (this._files && this._files.length > 0) {
+        Polymer.dom.flush();
+        const files = Array.from(
+            Polymer.dom(this.root).querySelectorAll('.file-row'));
+        this.$.fileCursor.stops = files;
+        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+      }
     },
 
     _incrementNumFilesShown() {
@@ -947,6 +948,11 @@
     },
 
     _computePatchSetDescription(revisions, patchNum) {
+      // Polymer 2: check for undefined
+      if ([revisions, patchNum].some(arg => arg === undefined)) {
+        return '';
+      }
+
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
         rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
@@ -1178,7 +1184,8 @@
     /**
      * Compute size bar layout values from the file list.
      *
-     * @return {Defs.LayoutStats|undefined}
+     * @return {Gerrit.LayoutStats|undefined}
+     *
      */
     _computeSizeBarLayout(shownFilesRecord) {
       if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
@@ -1214,7 +1221,7 @@
      * Get the width of the addition bar for a file.
      *
      * @param {Object} file
-     * @param {Defs.LayoutStats} stats
+     * @param {Gerrit.LayoutStats} stats
      * @return {number}
      */
     _computeBarAdditionWidth(file, stats) {
@@ -1232,7 +1239,7 @@
      * Get the x-offset of the addition bar for a file.
      *
      * @param {Object} file
-     * @param {Defs.LayoutStats} stats
+     * @param {Gerrit.LayoutStats} stats
      * @return {number}
      */
     _computeBarAdditionX(file, stats) {
@@ -1244,7 +1251,7 @@
      * Get the width of the deletion bar for a file.
      *
      * @param {Object} file
-     * @param {Defs.LayoutStats} stats
+     * @param {Gerrit.LayoutStats} stats
      * @return {number}
      */
     _computeBarDeletionWidth(file, stats) {
@@ -1261,7 +1268,8 @@
     /**
      * Get the x-offset of the deletion bar for a file.
      *
-     * @param {Defs.LayoutStats} stats
+     * @param {Gerrit.LayoutStats} stats
+     *
      * @return {number}
      */
     _computeBarDeletionX(stats) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index fffac8e..4c102a2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <script src="../../../scripts/util.js"></script>
 
@@ -815,18 +817,18 @@
       assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
 
-      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
       MockInteractions.tap(markReviewLabel);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
       assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-      assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
 
       MockInteractions.tap(markReviewLabel);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
       assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-      assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
     });
 
     test('_computeFileStatusLabel', () => {
@@ -834,7 +836,7 @@
       assert.equal(element._computeFileStatusLabel('M'), 'Modified');
     });
 
-    test('_handleFileListTap', () => {
+    test('_handleFileListClick', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
         'f1.txt': {},
@@ -846,7 +848,7 @@
         patchNum: '2',
       };
 
-      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
       const reviewStub = sandbox.stub(element, '_reviewFile');
       const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
 
@@ -856,26 +858,26 @@
       // Click on the expand button, resulting in _togglePathExpanded being
       // called and not resulting in a call to _reviewFile.
       row.querySelector('div.show-hide').click();
-      assert.isTrue(tapSpy.calledOnce);
+      assert.isTrue(clickSpy.calledOnce);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
 
       // Click inside the diff. This should result in no additional calls to
       // _togglePathExpanded or _reviewFile.
       Polymer.dom(element.root).querySelector('gr-diff-host').click();
-      assert.isTrue(tapSpy.calledTwice);
+      assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
 
       // Click the reviewed checkbox, resulting in a call to _reviewFile, but
       // no additional call to _togglePathExpanded.
       row.querySelector('.markReviewed').click();
-      assert.isTrue(tapSpy.calledThrice);
+      assert.isTrue(clickSpy.calledThrice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isTrue(reviewStub.calledOnce);
     });
 
-    test('_handleFileListTap editMode', () => {
+    test('_handleFileListClick editMode', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
         'f1.txt': {},
@@ -888,12 +890,12 @@
       };
       element.editMode = true;
       flushAsynchronousOperations();
-      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
       const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
 
-      // Tap the edit controls. Should be ignored by _handleFileListTap.
+      // Tap the edit controls. Should be ignored by _handleFileListClick.
       MockInteractions.tap(element.$$('.editFileControls'));
-      assert.isTrue(tapSpy.calledOnce);
+      assert.isTrue(clickSpy.calledOnce);
       assert.isFalse(toggleExpandSpy.called);
     });
 
@@ -1211,22 +1213,6 @@
       assert.isFalse(element.classList.contains('loading'));
     });
 
-    test('no execute _computeDiffURL before patchNum is knwon', done => {
-      const urlStub = sandbox.stub(element, '_computeDiffURL');
-      element.change = {_number: 123};
-      element.patchRange = {patchNum: undefined, basePatchNum: 'PARENT'};
-      element._filesByPath = {'foo/bar.cpp': {}};
-      element.editMode = false;
-      flush(() => {
-        assert.isFalse(urlStub.called);
-        element.set('patchRange.patchNum', 4);
-        flush(() => {
-          assert.isTrue(urlStub.called);
-          done();
-        });
-      });
-    });
-
     suite('size bars', () => {
       test('_computeSizeBarLayout', () => {
         assert.isUndefined(element._computeSizeBarLayout(null));
@@ -1713,7 +1699,9 @@
 
       // Commit message should not have edit controls.
       const editControls =
-          Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)')
+          Array.from(
+              Polymer.dom(element.root)
+                  .querySelectorAll('.row:not(.header-row)'))
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
index b824f1c..7a6aa7f 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
@@ -15,8 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.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-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-included-in-dialog">
@@ -27,44 +30,41 @@
         display: block;
         max-height: 80vh;
         overflow-y: auto;
-        padding: 4.5em 1em 1em 1em;
+        padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
       }
       header {
         background-color: var(--dialog-background-color);
         border-bottom: 1px solid var(--border-color);
         left: 0;
-        padding: 1em;
+        padding: var(--spacing-l);
         position: absolute;
         right: 0;
         top: 0;
       }
       #title {
         display: inline-block;
-        font-size: 1.2rem;
-        margin-top: .2em;
-      }
-      h2 {
-        font-size: 1rem;
+        font-size: var(--font-size-h3);
+        margin-top: var(--spacing-xs);
       }
       #filterInput {
         display: inline-block;
         float: right;
-        margin: 0 1em;
-        padding: .2em;
+        margin: 0 var(--spacing-l);
+        padding: var(--spacing-xs);
       }
       .closeButtonContainer {
         float: right;
       }
       ul {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       ul li {
         border: 1px solid var(--border-color);
-        border-radius: .2em;
+        border-radius: var(--border-radius);
         background: var(--chip-background-color);
         display: inline-block;
-        margin: 0 .2em .4em .2em;
-        padding: .2em .4em;
+        margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
+        padding: var(--spacing-xs) var(--spacing-s);
       }
       .loading.loaded {
         display: none;
@@ -75,13 +75,17 @@
       <span class="closeButtonContainer">
         <gr-button id="closeButton"
             link
-            on-tap="_handleCloseTap">Close</gr-button>
+            on-click="_handleCloseTap">Close</gr-button>
       </span>
-      <input
-          id="filterInput"
-          is="iron-input"
+      <iron-input
           placeholder="Filter"
           on-bind-value-changed="_onFilterChanged">
+        <input
+            id="filterInput"
+            is="iron-input"
+            placeholder="Filter"
+            on-bind-value-changed="_onFilterChanged">
+      </iron-input>
     </header>
     <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
     <template
@@ -89,7 +93,7 @@
         items="[[_computeGroups(_includedIn, _filterText)]]"
         as="group">
       <div>
-        <h2>[[group.title]]:</h2>
+        <span>[[group.title]]:</span>
         <ul>
           <template is="dom-repeat" items="[[group.items]]">
             <li>[[item]]</li>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index 7755a60..4b8ce22 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-included-in-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -45,6 +44,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     loadData() {
       if (!this.changeNum) { return; }
       this._filterText = '';
@@ -84,6 +87,7 @@
 
     _handleCloseTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('close', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index 539011a..68c77e6 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-included-in-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-included-in-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
index 27c5baa..220546b 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-selector/iron-selector.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -28,12 +28,12 @@
       .labelContainer {
         align-items: center;
         display: flex;
-        margin-bottom: .5em;
+        margin-bottom: var(--spacing-m);
       }
       .labelName {
         display: inline-block;
         flex: 0 0 auto;
-        margin-right: .5em;
+        margin-right: var(--spacing-m);
         min-width: 7em;
         text-align: left;
         width: 20%;
@@ -47,7 +47,7 @@
       .selectedValueText {
         color: var(--deemphasized-text-color);
         font-style: italic;
-        margin: 0 .5em 0 .5em;
+        margin: 0 var(--spacing-m);
       }
       .selectedValueText.hidden {
         display: none;
@@ -60,7 +60,7 @@
         --gr-button: {
           background-color: var(--button-background-color, var(--table-header-background-color));
           color: var(--primary-text-color);
-          padding: .2em .85em;
+          padding: var(--spacing-xs) var(--spacing-m);
           @apply --vote-chip-styles;
         }
       }
@@ -108,6 +108,7 @@
           <span class="placeholder" data-label$="[[label.name]]"></span>
         </template>
         <iron-selector
+            id="labelSelector"
             attr-for-selected="value"
             selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
             hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 9b5847d..76e6e64 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-label-score-row',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when any label is changed.
@@ -66,7 +65,7 @@
     },
 
     get _ironSelector() {
-      return this.$$('iron-selector');
+      return this.$ && this.$.labelSelector;
     },
 
     _computeBlankItems(permittedLabels, label, side) {
@@ -97,19 +96,30 @@
     },
 
     _computeButtonClass(value, index, totalItems) {
-      if (value < 0 && index === 0) {
-        return 'min';
-      } else if (value < 0) {
-        return 'negative';
-      } else if (value > 0 && index === totalItems - 1) {
-        return 'max';
-      } else if (value > 0) {
-        return 'positive';
+      const classes = [];
+      if (value === this.selectedValue) {
+        classes.push('iron-selected');
       }
-      return 'neutral';
+
+      if (value < 0 && index === 0) {
+        classes.push('min');
+      } else if (value < 0) {
+        classes.push('negative');
+      } else if (value > 0 && index === totalItems - 1) {
+        classes.push('max');
+      } else if (value > 0) {
+        classes.push('positive');
+      } else {
+        classes.push('neutral');
+      }
+
+      return classes.join(' ');
     },
 
     _computeLabelValue(labels, permittedLabels, label) {
+      if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
+        return null;
+      }
       if (!labels[label.name]) { return null; }
       const labelValue = this._getLabelValue(labels, permittedLabels, label);
       const len = permittedLabels[label.name] != null ?
@@ -133,11 +143,12 @@
       const name = e.target.selectedItem.name;
       const value = e.target.selectedItem.getAttribute('value');
       this.dispatchEvent(new CustomEvent(
-          'labels-changed', {detail: {name, value}, bubbles: true}));
+          'labels-changed',
+          {detail: {name, value}, bubbles: true, composed: true}));
     },
 
     _computeAnyPermittedLabelValues(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label) &&
+      return permittedLabels && permittedLabels.hasOwnProperty(label) &&
         permittedLabels[label].length;
     },
 
@@ -147,6 +158,11 @@
     },
 
     _computePermittedLabelValues(permittedLabels, label) {
+      // Polymer 2: check for undefined
+      if ([permittedLabels, label].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return permittedLabels[label];
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index 1e4d471..519fbb8 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-score-row</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-label-score-row.html">
@@ -106,7 +108,7 @@
     test('label picker', () => {
       const labelsChangedHandler = sandbox.stub();
       element.addEventListener('labels-changed', labelsChangedHandler);
-      assert.ok(element.$$('iron-selector'));
+      assert.ok(element.$.labelSelector);
       MockInteractions.tap(element.$$(
           'gr-button[value="-1"]'));
       flushAsynchronousOperations();
@@ -155,9 +157,9 @@
 
     test('correct item is selected', () => {
       // 1 should be the value of the selected item
-      assert.strictEqual(element.$$('iron-selector').selected, '+1');
+      assert.strictEqual(element.$.labelSelector.selected, '+1');
       assert.strictEqual(
-          element.$$('iron-selector').selectedItem
+          element.$.labelSelector.selectedItem
               .textContent.trim(), '+1');
       assert.strictEqual(
           element.$.selectedValueLabel.textContent.trim(), 'good');
@@ -234,7 +236,7 @@
           default_value: 0,
         },
       };
-      const selector = element.$$('iron-selector');
+      const selector = element.$.labelSelector;
       element.set('label', {name: 'Verified', value: ' 0'});
       flushAsynchronousOperations();
       assert.strictEqual(selector.selected, ' 0');
@@ -251,21 +253,21 @@
         ],
       };
       flushAsynchronousOperations();
-      assert.isOk(element.$$('iron-selector'));
-      assert.isFalse(element.$$('iron-selector').hidden);
+      assert.isOk(element.$.labelSelector);
+      assert.isFalse(element.$.labelSelector.hidden);
 
       element.permittedLabels = {};
       flushAsynchronousOperations();
-      assert.isOk(element.$$('iron-selector'));
-      assert.isTrue(element.$$('iron-selector').hidden);
+      assert.isOk(element.$.labelSelector);
+      assert.isTrue(element.$.labelSelector.hidden);
 
       element.permittedLabels = {Verified: []};
       flushAsynchronousOperations();
-      assert.isOk(element.$$('iron-selector'));
-      assert.isTrue(element.$$('iron-selector').hidden);
+      assert.isOk(element.$.labelSelector);
+      assert.isTrue(element.$.labelSelector.hidden);
     });
 
-    test('asymetrical labels', () => {
+    test('asymetrical labels', done => {
       element.permittedLabels = {
         'Code-Review': [
           '-2',
@@ -279,30 +281,35 @@
           '+1',
         ],
       };
-      flushAsynchronousOperations();
-      assert.strictEqual(element.$$('iron-selector')
-          .items.length, 2);
-      assert.strictEqual(Polymer.dom(element.root).
-          querySelectorAll('.placeholder').length, 3);
+      flush(() => {
+        assert.strictEqual(element.$.labelSelector
+            .items.length, 2);
+        assert.strictEqual(
+            Polymer.dom(element.root).querySelectorAll('.placeholder').length,
+            3);
 
-      element.permittedLabels = {
-        'Code-Review': [
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-      };
-      flushAsynchronousOperations();
-      assert.strictEqual(element.$$('iron-selector')
-          .items.length, 5);
-      assert.strictEqual(Polymer.dom(element.root).
-          querySelectorAll('.placeholder').length, 0);
+        element.permittedLabels = {
+          'Code-Review': [
+            ' 0',
+            '+1',
+          ],
+          'Verified': [
+            '-2',
+            '-1',
+            ' 0',
+            '+1',
+            '+2',
+          ],
+        };
+        flush(() => {
+          assert.strictEqual(element.$.labelSelector
+              .items.length, 5);
+          assert.strictEqual(
+              Polymer.dom(element.root).querySelectorAll('.placeholder').length,
+              0);
+          done();
+        });
+      });
     });
 
     test('default_value', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
index 7dd4c76..c607a9f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-label-score-row/gr-label-score-row.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -28,6 +28,9 @@
         text-align: center;
         width: 100%;
       }
+      gr-label-score-row.no-access {
+        display: var(--label-no-access-display, initial);
+      }
       @media only screen and (max-width: 25em) {
         :host {
           text-align: center;
@@ -36,6 +39,7 @@
     </style>
     <template is="dom-repeat" items="[[_labels]]" as="label">
       <gr-label-score-row
+          class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
           label="[[label]]"
           name="[[label.name]]"
           labels="[[change.labels]]"
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index eaf39bc..dffba3e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-label-scores',
-    _legacyUndefinedCheck: true,
+
     properties: {
       _labels: {
         type: Array,
@@ -82,7 +82,12 @@
       return null;
     },
 
-    _computeLabels(labelRecord) {
+    _computeLabels(labelRecord, account) {
+      // Polymer 2: check for undefined
+      if ([labelRecord, account].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const labelsObj = labelRecord.base;
       if (!labelsObj) { return []; }
       return Object.keys(labelsObj).sort().map(key => {
@@ -115,5 +120,19 @@
     _changeIsMerged(changeStatus) {
       return changeStatus === 'MERGED';
     },
+
+    /**
+     * @param {string|undefined} label
+     * @param {Object|undefined} permittedLabels
+     * @return {string}
+     */
+    _computeLabelAccessClass(label, permittedLabels) {
+      if (label == null || permittedLabels == null) {
+        return '';
+      }
+
+      return permittedLabels.hasOwnProperty(label) &&
+        permittedLabels[label].length ? 'access' : 'no-access';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
index 187c0a6..b8d471c 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-scores</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-label-scores.html">
 
@@ -135,6 +137,25 @@
       });
     });
 
+    test('_computeLabelAccessClass undefined case', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass('', undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, {}), '');
+    });
+
+    test('_computeLabelAccessClass has access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+    });
+
+    test('_computeLabelAccessClass no access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+    });
+
     test('changes in label score are reflected in _labels', () => {
       element.change = {
         _number: '123',
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 7b49d2a..da5eb39 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -15,9 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../shared/gr-account-label/gr-account-label.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
@@ -36,16 +38,17 @@
         display: block;
         position: relative;
         cursor: pointer;
+        overflow-y: hidden;
       }
       :host(.expanded) {
         cursor: auto;
       }
       :host > div {
-        padding: 0 var(--default-horizontal-margin);
+        padding: 0 var(--spacing-l);
       }
       gr-avatar {
         position: absolute;
-        left: var(--default-horizontal-margin);
+        left: var(--spacing-l);
       }
       .collapsed .contentContainer {
         align-items: baseline;
@@ -54,11 +57,11 @@
         white-space: nowrap;
       }
       .contentContainer {
-        margin-left: calc(var(--default-horizontal-margin) + 2.5em);
-        padding: 10px 0;
+        margin-left: calc(var(--spacing-l) + 2.5em);
+        padding: var(--spacing-m) 0;
       }
       .showAvatar.collapsed .contentContainer {
-        margin-left: calc(var(--default-horizontal-margin) + 1.75em);
+        margin-left: calc(var(--spacing-l) + 1.75em);
       }
       .hideAvatar.collapsed .contentContainer,
       .hideAvatar.expanded .contentContainer {
@@ -67,17 +70,17 @@
       .showAvatar.collapsed .contentContainer,
       .hideAvatar.collapsed .contentContainer,
       .hideAvatar.expanded .contentContainer {
-        padding: .75em 0;
+        padding: var(--spacing-m) 0;
       }
       .collapsed gr-avatar {
-        top: .5em;
-        height: 1.75em;
-        width: 1.75em;
+        top: var(--spacing-m);
+        height: var(--line-height-normal);
+        width: var(--line-height-normal);
       }
       .expanded gr-avatar {
-        top: 12px;
-        height: 2.5em;
-        width: 2.5em;
+        top: var(--spacing-l);
+        height: var(--line-height-h1);
+        width: var(--line-height-h1);
       }
       .name {
         font-weight: var(--font-weight-bold);
@@ -111,7 +114,7 @@
       }
       .collapsed .content {
         flex: 1;
-        margin-right: .25em;
+        margin-right: var(--spacing-xs);
         min-width: 0;
         overflow: hidden;
         text-overflow: ellipsis;
@@ -122,15 +125,15 @@
       .collapsed .author {
         overflow: hidden;
         color: var(--primary-text-color);
-        margin-right: .4em;
+        margin-right: var(--spacing-s);
       }
       .expanded .author {
         cursor: pointer;
-        margin-bottom: .4em;
+        margin-bottom: var(--spacing-s);
       }
       .dateContainer {
         position: absolute;
-        right: var(--default-horizontal-margin);
+        right: var(--spacing-l);
         top: 10px;
       }
       span.date {
@@ -141,17 +144,18 @@
       }
       .dateContainer iron-icon {
         cursor: pointer;
+        vertical-align: top;
       }
       .replyContainer {
-        padding: .5em 0 0 0;
+        padding: var(--spacing-m) 0 0 0;
       }
       .score {
         border: 1px solid rgba(0,0,0,.12);
-        border-radius: 3px;
+        border-radius: var(--border-radius);
         color: var(--primary-text-color);
         display: inline-block;
-        margin: -.1em 0;
-        padding: 0 .1em;
+        margin: -1px 0;
+        padding: 0 var(--spacing-xxs);
       }
       .score.negative {
         background-color: var(--vote-color-disliked);
@@ -174,7 +178,7 @@
     <div class$="[[_computeClass(_expanded, showAvatar, message)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
-        <div class="author" on-tap="_handleAuthorTap">
+        <div class="author" on-click="_handleAuthorClick">
           <span hidden$="[[!showOnBehalfOf]]">
             <span class="name">[[message.real_author.name]]</span>
             on behalf of
@@ -197,7 +201,7 @@
                 content="[[message.message]]"
                 config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
             <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
-              <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
+              <gr-button link small on-click="_handleReplyTap">Reply</gr-button>
             </div>
             <gr-comment-list
                 comments="[[comments]]"
@@ -231,7 +235,7 @@
             </span>
           </template>
           <template is="dom-if" if="[[message.id]]">
-            <span class="date" on-tap="_handleAnchorTap">
+            <span class="date" on-click="_handleAnchorClick">
               <gr-date-formatter
                   has-tooltip
                   show-date-and-time
@@ -240,7 +244,7 @@
           </template>
           <iron-icon
               id="expandToggle"
-              on-tap="_toggleExpanded"
+              on-click="_toggleExpanded"
               title="Toggle expanded state"
               icon="[[_computeExpandToggleIcon(_expanded)]]">
         </span>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 837ffe0..26e0cd3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-message',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when this message's reply link is tapped.
@@ -37,7 +36,7 @@
      */
 
     listeners: {
-      tap: '_handleTap',
+      click: '_handleClick',
     },
 
     properties: {
@@ -104,6 +103,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_updateExpandedClass(message.expanded)',
     ],
@@ -140,7 +143,7 @@
     },
 
     _computeShowReplyButton(message, loggedIn) {
-      return !!message.message && loggedIn &&
+      return message && !!message.message && loggedIn &&
           !this._computeIsAutomated(message);
     },
 
@@ -159,13 +162,13 @@
       }
     },
 
-    _handleTap(e) {
+    _handleClick(e) {
       if (this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', true);
     },
 
-    _handleAuthorTap(e) {
+    _handleAuthorClick(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
@@ -199,6 +202,10 @@
     },
 
     _computeScoreClass(score, labelExtremes) {
+      // Polymer 2: check for undefined
+      if ([score, labelExtremes].some(arg => arg === undefined)) {
+        return '';
+      }
       const classes = [];
       if (score.value > 0) {
         classes.push('positive');
@@ -224,10 +231,11 @@
       return classes.join(' ');
     },
 
-    _handleAnchorTap(e) {
+    _handleAnchorClick(e) {
       e.preventDefault();
       this.dispatchEvent(new CustomEvent('message-anchor-tap', {
         bubbles: true,
+        composed: true,
         detail: {id: this.message.id},
       }));
     },
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 64a5b26..ef5a756 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-message</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-message.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 80708a1..d71beb4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-message/gr-message.html">
@@ -38,10 +38,10 @@
         display: flex;
         justify-content: space-between;
         min-height: 3.2em;
-        padding: .5em var(--default-horizontal-margin);
+        padding: var(--spacing-s) var(--spacing-l);
       }
       #messageControlsContainer {
-        padding: 0 var(--default-horizontal-margin);
+        padding: 0 var(--spacing-l);
       }
       .highlighted {
         animation: 3s fadeOut;
@@ -58,7 +58,7 @@
         justify-content: center;
       }
       #messageControlsContainer gr-button {
-        padding: 0.4em 0;
+        padding: var(--spacing-s) 0;
       }
       .container {
         align-items: center;
@@ -78,14 +78,14 @@
         <gr-button
             id="collapse-messages"
             link
-            on-tap="_handleExpandCollapseTap">
+            on-click="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
       </div>
     <span
         id="messageControlsContainer"
         hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
-      <gr-button id="oldMessagesBtn" link on-tap="_handleShowAllTap">
+      <gr-button id="oldMessagesBtn" link on-click="_handleShowAllTap">
           [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
       </gr-button>
       <span
@@ -93,7 +93,7 @@
           hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
         <span class="transparent separator"></span>
         <gr-button id="incrementMessagesBtn" link
-            on-tap="_handleIncrementShownMessages">
+            on-click="_handleIncrementShownMessages">
           [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
         </gr-button>
       </span>
@@ -109,7 +109,7 @@
           hide-automated="[[_hideAutomated]]"
           project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
-          on-message-anchor-tap="_handleAnchorTap"
+          on-message-anchor-tap="_handleAnchorClick"
           label-extremes="[[_labelExtremes]]"
           data-message-id$="[[message.id]]"></gr-message>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index bd23a40..5b6bfbf 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -27,7 +27,6 @@
 
   Polymer({
     is: 'gr-messages-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       changeNum: Number,
@@ -117,6 +116,11 @@
     },
 
     _computeItems(messages, reviewerUpdates) {
+      // Polymer 2: check for undefined
+      if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
+        return [];
+      }
+
       messages = messages || [];
       reviewerUpdates = reviewerUpdates || [];
       let mi = 0;
@@ -151,10 +155,18 @@
     },
 
     _expandedChanged(exp) {
-      for (let i = 0; i < this._processedMessages.length; i++) {
-        this._processedMessages[i].expanded = exp;
-        if (i < this._visibleMessages.length) {
-          this.set(['_visibleMessages', i, 'expanded'], exp);
+      if (this._processedMessages) {
+        for (let i = 0; i < this._processedMessages.length; i++) {
+          this._processedMessages[i].expanded = exp;
+        }
+      }
+      // _visibleMessages is a subarray of _processedMessages
+      // _processedMessages contains all items from _visibleMessages
+      // At this point all _visibleMessages.expanded values are set,
+      // and notifyPath must be used to notify Polymer about changes.
+      if (this._visibleMessages) {
+        for (let i = 0; i < this._visibleMessages.length; i++) {
+          this.notifyPath(`_visibleMessages.${i}.expanded`);
         }
       }
     },
@@ -185,7 +197,7 @@
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleAnchorTap(e) {
+    _handleAnchorClick(e) {
       this.scrollToMessage(e.detail.id);
     },
 
@@ -215,6 +227,9 @@
      * @return {!Object} Hash of arrays of comments, filename as key.
      */
     _computeCommentsForMessage(changeComments, message) {
+      if ([changeComments, message].some(arg => arg === undefined)) {
+        return [];
+      }
       const comments = changeComments.getAllPublishedComments();
       if (message._index === undefined || !comments || !this.messages) {
         return [];
@@ -263,8 +278,13 @@
      * more visible messages in the list.
      */
     _getDelta(visibleMessages, messages, hideAutomated) {
+      if ([visibleMessages, messages].some(arg => arg === undefined)) {
+        return 0;
+      }
+
       let delta = MESSAGES_INCREMENT;
       const msgsRemaining = messages.length - visibleMessages.length;
+
       if (hideAutomated) {
         let counter = 0;
         let i;
@@ -281,6 +301,10 @@
      * exist in _visibleMessages.
      */
     _numRemaining(visibleMessages, messages, hideAutomated) {
+      if ([visibleMessages, messages].some(arg => arg === undefined)) {
+        return 0;
+      }
+
       if (hideAutomated) {
         return this._getHumanMessages(messages).length -
             this._getHumanMessages(visibleMessages).length;
@@ -303,6 +327,10 @@
 
     _computeShowHideTextHidden(visibleMessages, messages,
         hideAutomated) {
+      if ([visibleMessages, messages].some(arg => arg === undefined)) {
+        return 0;
+      }
+
       if (hideAutomated) {
         messages = this._getHumanMessages(messages);
         visibleMessages = this._getHumanMessages(visibleMessages);
@@ -326,7 +354,9 @@
     },
 
     _processedMessagesChanged(messages) {
-      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
+      if (messages) {
+        this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
+      }
     },
 
     _computeNumMessagesText(visibleMessages, messages,
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 9572de4..315403e 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-messages-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../diff/gr-comment-api/gr-comment-api.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 30ebc08..696ffdf 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -14,8 +14,9 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -29,7 +30,7 @@
         display: block;
       }
       h3 {
-        margin: .5em 0 0;
+        margin: var(--spacing-m) 0 0;
       }
       section {
         margin-bottom: 1.4em; /* Same as line height for collapse purposes */
@@ -74,7 +75,7 @@
       .status {
         color: var(--deemphasized-text-color);
         font-weight: var(--font-weight-bold);
-        margin-left: .25em;
+        margin-left: var(--spacing-xs);
       }
       .notCurrent {
         color: #e65100;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 07f6e20..9103f4f 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-related-changes-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a new section is loaded so that the change view can determine
@@ -78,6 +77,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
@@ -121,7 +121,7 @@
       ];
 
       // Get conflicts if change is open and is mergeable.
-      if (this.changeIsOpen(this.change.status) && this.mergeable) {
+      if (this.changeIsOpen(this.change) && this.mergeable) {
         promises.push(this._getConflicts().then(response => {
           // Because the server doesn't always return a response and the
           // template expects an array, always return an array.
@@ -208,6 +208,9 @@
 
     _computeChangeContainerClass(currentChange, relatedChange) {
       const classes = ['changeContainer'];
+      if ([relatedChange, currentChange].some(arg => arg === undefined)) {
+        return classes;
+      }
       if (this._changesEqual(relatedChange, currentChange)) {
         classes.push('thisChange');
       }
@@ -244,6 +247,9 @@
      * @return {number}
      */
     _getChangeNumber(change) {
+      // Default to 0 if change property is not defined.
+      if (!change) return 0;
+
       if (change.hasOwnProperty('_change_number')) {
         return change._change_number;
       }
@@ -294,6 +300,17 @@
 
     _resultsChanged(related, submittedTogether, conflicts,
         cherryPicks, sameTopic) {
+      // Polymer 2: check for undefined
+      if ([
+        related,
+        submittedTogether,
+        conflicts,
+        cherryPicks,
+        sameTopic,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       const results = [
         related && related.changes,
         submittedTogether && submittedTogether.changes,
@@ -316,8 +333,14 @@
     },
 
     _computeConnectedRevisions(change, patchNum, relatedChanges) {
+      // Polymer 2: check for undefined
+      if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const connected = [];
       let changeRevision;
+      if (!change) { return []; }
       for (const rev in change.revisions) {
         if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
           changeRevision = rev;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index c82bc31..f04d40e 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-related-changes-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-related-changes-list.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index 692cb93..3632348 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../plugins/gr-plugin-host/gr-plugin-host.html">
@@ -118,18 +120,18 @@
       // resolving.
       sandbox.stub(element, '_purgeReviewersPendingRemove');
 
-      element.$$('#ccs').$.entry.setText('test');
+      element.$.ccs.$.entry.setText('test');
       MockInteractions.tap(element.$$('gr-button.send'));
       assert.isFalse(sendStub.called);
       flushAsynchronousOperations();
 
-      element.$$('#ccs').$.entry.setText('test@test.test');
+      element.$.ccs.$.entry.setText('test@test.test');
       MockInteractions.tap(element.$$('gr-button.send'));
       assert.isTrue(sendStub.called);
     });
 
     test('lgtm plugin', done => {
-      Gerrit._resetPlugins();
+      Gerrit._testOnly_resetPlugins();
       const pluginHost = fixture('plugin-host');
       pluginHost.config = {
         plugin: {
@@ -149,7 +151,8 @@
           flush(() => {
             const textarea = element.$.textarea.getNativeTextarea();
             textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent('input', {bubbles: true}));
+            textarea.dispatchEvent(new CustomEvent(
+                'input', {bubbles: true, composed: true}));
             const labelScoreRows = Polymer.dom(element.$.labelScores.root)
                 .querySelector('gr-label-score-row[name="Code-Review"]');
             const selectedBtn = Polymer.dom(labelScoreRows.root)
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 f8d43cb..e836ccc 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
@@ -15,12 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
@@ -31,9 +32,12 @@
 <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">
+<link rel="import" href="../../change/gr-comment-list/gr-comment-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-reply-dialog">
   <template>
@@ -57,7 +61,7 @@
       section {
         border-top: 1px solid var(--border-color);
         flex-shrink: 0;
-        padding: .5em 1.5em;
+        padding: var(--spacing-m) var(--spacing-xl);
         width: 100%;
       }
       .actions {
@@ -70,7 +74,7 @@
         z-index: 1;
       }
       .actions .right gr-button {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       .peopleContainer,
       .labelsContainer {
@@ -82,13 +86,13 @@
       }
       .peopleList {
         display: flex;
-        padding-top: .1em;
+        padding-top: var(--spacing-xxs);
       }
       .peopleListLabel {
         color: var(--deemphasized-text-color);
-        margin-top: .2em;
+        margin-top: var(--spacing-xs);
         min-width: 7em;
-        padding-right: .5em;
+        padding-right: var(--spacing-m);
       }
       gr-account-list {
         display: flex;
@@ -97,11 +101,11 @@
         min-height: 1.8em;
       }
       #reviewerConfirmationOverlay {
-        padding: 1em;
+        padding: var(--spacing-l);
         text-align: center;
       }
       .reviewerConfirmationButtons {
-        margin-top: 1em;
+        margin-top: var(--spacing-l);
       }
       .groupName {
         font-weight: var(--font-weight-bold);
@@ -124,14 +128,14 @@
       }
       .previewContainer gr-formatted-text {
         background: var(--table-header-background-color);
-        padding: 1em;
+        padding: var(--spacing-l);
       }
       .draftsContainer h3 {
-        margin-top: .25em;
+        margin-top: var(--spacing-xs);
       }
       #checkingStatusLabel,
       #notLatestLabel {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       #checkingStatusLabel {
         color: var(--deemphasized-text-color);
@@ -149,8 +153,8 @@
       }
       #pluginMessage {
         color: var(--deemphasized-text-color);
-        margin-left: 1em;
-        margin-bottom: .5em;
+        margin-left: var(--spacing-l);
+        margin-bottom: var(--spacing-m);
       }
       #pluginMessage:empty {
         display: none;
@@ -164,11 +168,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">
@@ -176,12 +180,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="[[_getCcSuggestionsProvider(change)]]">
           </gr-account-list>
         </div>
         <gr-overlay
@@ -201,8 +205,8 @@
             Are you sure you want to add them all?
           </div>
           <div class="reviewerConfirmationButtons">
-            <gr-button on-tap="_confirmPendingReviewer">Yes</gr-button>
-            <gr-button on-tap="_cancelPendingReviewer">No</gr-button>
+            <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
+            <gr-button on-click="_cancelPendingReviewer">No</gr-button>
           </div>
         </gr-overlay>
       </section>
@@ -273,7 +277,7 @@
                 class="action save"
                 has-tooltip
                 title="[[_saveTooltip]]"
-                on-tap="_saveTapHandler">Save</gr-button>
+                on-click="_saveTapHandler">Save</gr-button>
           </template>
           <span
               id="checkingStatusLabel"
@@ -284,7 +288,7 @@
               id="notLatestLabel"
               hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
             [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-            <gr-button link on-tap="_reload">Reload</gr-button>
+            <gr-button link on-click="_reload">Reload</gr-button>
           </span>
         </div>
         <div class="right">
@@ -292,7 +296,7 @@
               link
               id="cancelButton"
               class="action cancel"
-              on-tap="_cancelTapHandler">Cancel</gr-button>
+              on-click="_cancelTapHandler">Cancel</gr-button>
           <gr-button
               id="sendButton"
               link
@@ -301,7 +305,7 @@
               class="action send"
               has-tooltip
               title$="[[_computeSendButtonTooltip(canBeStarted)]]"
-              on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+              on-click="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
         </div>
       </section>
     </div>
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 1ab961b..18ed5b8 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
@@ -48,17 +48,12 @@
     SEND: 'Send reply',
   };
 
-  // TODO(logan): Remove once the fix for issue 6841 is stable on
-  // googlesource.com.
-  const START_REVIEW_MESSAGE = 'This change is ready for review.';
-
   const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
   const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
   Polymer({
     is: 'gr-reply-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a reply is successfully sent.
@@ -220,12 +215,9 @@
 
     FocusTarget,
 
-    // TODO(logan): Remove once the fix for issue 6841 is stable on
-    // googlesource.com.
-    START_REVIEW_MESSAGE,
-
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
@@ -314,31 +306,37 @@
     },
 
     _ccsChanged(splices) {
-      if (splices && splices.indexSplices) {
-        this._reviewersMutated = true;
-        this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC);
-      }
+      this._reviewerTypeChanged(splices, ReviewerTypes.CC);
     },
 
     _reviewersChanged(splices) {
+      this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER);
+    },
+
+    _reviewerTypeChanged(splices, reviewerType) {
       if (splices && splices.indexSplices) {
         this._reviewersMutated = true;
         this._processReviewerChange(splices.indexSplices,
-            ReviewerTypes.REVIEWER);
+            reviewerType);
         let key;
         let index;
         let account;
-        // Remove any accounts that already exist as a CC.
+        // Remove any accounts that already exist as a CC for reviewer
+        // or vice versa.
+        const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
         for (const splice of splices.indexSplices) {
-          for (const addedKey of splice.addedKeys) {
-            account = this.get(`_reviewers.${addedKey}`);
+          for (let i = 0; i < splice.addedCount; i++) {
+            account = splice.object[splice.index + i];
             key = this._accountOrGroupKey(account);
-            index = this._ccs.findIndex(
+            const array = isReviewer ? this._ccs : this._reviewers;
+            index = array.findIndex(
                 account => this._accountOrGroupKey(account) === key);
             if (index >= 0) {
-              this.splice('_ccs', index, 1);
+              this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
+              const moveFrom = isReviewer ? 'CC' : 'reviewer';
+              const moveTo = isReviewer ? 'reviewer' : 'CC';
               const message = (account.name || account.email || key) +
-                  ' moved from CC to reviewer.';
+                  ` moved from ${moveFrom} to ${moveTo}.`;
               this.fire('show-alert', {message});
             }
           }
@@ -390,9 +388,6 @@
      *
      * @param {!Object} account
      * @param {string} type
-     *
-     * * TODO(beckysiegel) submit Polymer PR
-     * @suppress {checkTypes}
      */
     _removeAccount(account, type) {
       if (account._pendingAdd) { return; }
@@ -404,7 +399,7 @@
         const reviewers = this.change.reviewers[type] || [];
         for (let i = 0; i < reviewers.length; i++) {
           if (reviewers[i]._account_id == account._account_id) {
-            this.splice(['change', 'reviewers', type], i, 1);
+            this.splice(`change.reviewers.${type}`, i, 1);
             break;
           }
         }
@@ -447,7 +442,7 @@
         }
         return this._mapReviewer(reviewer);
       });
-      const ccsEl = this.$$('#ccs');
+      const ccsEl = this.$.ccs;
       if (ccsEl) {
         for (let reviewer of ccsEl.additions()) {
           if (reviewer.account) {
@@ -461,13 +456,6 @@
 
       this.disabled = true;
 
-      if (obj.ready && !obj.message) {
-        // TODO(logan): The server currently doesn't send email in this case.
-        // Insert a dummy message to force an email to be sent. Remove this
-        // once the fix for issue 6841 is stable on googlesource.com.
-        obj.message = START_REVIEW_MESSAGE;
-      }
-
       const errFn = this._handle400Error.bind(this);
       return this._saveReview(obj, errFn).then(response => {
         if (!response) {
@@ -480,18 +468,10 @@
           return {};
         }
 
-        // TODO(logan): Remove once the required API changes are live and stable
-        // on googlesource.com.
-        return this._maybeSetReady(startReview, response).catch(err => {
-          // We catch error here because we still want to treat this as a
-          // successful review.
-          console.error('error setting ready:', err);
-        }).then(() => {
-          this.draft = '';
-          this._includeComments = true;
-          this.fire('send', null, {bubbles: false});
-          return accountAdditions;
-        });
+        this.draft = '';
+        this._includeComments = true;
+        this.fire('send', null, {bubbles: false});
+        return accountAdditions;
       }).then(result => {
         this.disabled = false;
         return result;
@@ -501,32 +481,6 @@
       });
     },
 
-    /**
-     * Returns a promise resolving to true if review was successfully posted,
-     * false otherwise.
-     *
-     * TODO(logan): Remove this once the required API changes are live and
-     * stable on googlesource.com.
-     */
-    _maybeSetReady(startReview, response) {
-      return this.$.restAPI.getResponseObject(response).then(result => {
-        if (!startReview || result.ready) {
-          return Promise.resolve();
-        }
-        // We don't have confirmation that review was started, so attempt to
-        // start review explicitly.
-        return this.$.restAPI.startReview(
-            this.change._number, null, response => {
-              // If we see a 409 response code, then that means the server
-              // *does* support moving from WIP->ready when posting a
-              // review. Only alert user for non-409 failures.
-              if (response.status !== 409) {
-                this.fire('server-error', {response});
-              }
-            });
-      });
-    },
-
     _focusOn(section) {
       // Safeguard- always want to focus on something.
       if (!section || section === FocusTarget.ANY) {
@@ -540,7 +494,7 @@
         const reviewerEntry = this.$.reviewers.focusStart;
         reviewerEntry.async(reviewerEntry.focus);
       } else if (section === FocusTarget.CCS) {
-        const ccEntry = this.$$('#ccs').focusStart;
+        const ccEntry = this.$.ccs.focusStart;
         ccEntry.async(ccEntry.focus);
       }
     },
@@ -571,31 +525,31 @@
       //
       this.disabled = false;
 
-      if (response.status !== 400) {
-        // This is all restAPI does when there is no custom error handling.
-        this.fire('server-error', {response});
-        return response;
-      }
-
-      // Process the response body, format a better error message, and fire
-      // an event for gr-event-manager to display.
-      const jsonPromise = this.$.restAPI.getResponseObject(response);
+      // Using response.clone() here, because getResponseObject() and
+      // potentially the generic error handler will want to call text() on the
+      // response object, which can only be done once per object.
+      const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
       return jsonPromise.then(result => {
-        const errors = [];
-        for (const state of ['reviewers', 'ccs']) {
-          if (!result.hasOwnProperty(state)) { continue; }
-          for (const reviewer of Object.values(result[state])) {
-            if (reviewer.error) {
-              errors.push(reviewer.error);
+        // Only perform custom error handling for 400s and a parseable
+        // ReviewResult response.
+        if (response.status === 400 && result) {
+          const errors = [];
+          for (const state of ['reviewers', 'ccs']) {
+            if (!result.hasOwnProperty(state)) { continue; }
+            for (const reviewer of Object.values(result[state])) {
+              if (reviewer.error) {
+                errors.push(reviewer.error);
+              }
             }
           }
+          response = {
+            ok: false,
+            status: response.status,
+            text() { return Promise.resolve(errors.join(', ')); },
+          };
         }
-        response = {
-          ok: false,
-          status: response.status,
-          text() { return Promise.resolve(errors.join(', ')); },
-        };
         this.fire('server-error', {response});
+        return null; // Means that the error has been handled.
       });
     },
 
@@ -622,6 +576,11 @@
     },
 
     _changeUpdated(changeRecord, owner) {
+      // Polymer 2: check for undefined
+      if ([changeRecord, owner].some(arg => arg === undefined)) {
+        return;
+      }
+
       this._rebuildReviewerArrays(changeRecord.base, owner);
     },
 
@@ -712,7 +671,7 @@
 
     _saveTapHandler(e) {
       e.preventDefault();
-      if (!this.$$('#ccs').submitEntryText()) {
+      if (!this.$.ccs.submitEntryText()) {
         // Do not proceed with the save if there is an invalid email entry in
         // the text field of the CC entry.
         return;
@@ -728,7 +687,7 @@
     },
 
     _submit() {
-      if (!this.$$('#ccs').submitEntryText()) {
+      if (!this.$.ccs.submitEntryText()) {
         // Do not proceed with the send if there is an invalid email entry in
         // the text field of the CC entry.
         return;
@@ -736,6 +695,7 @@
       if (this._sendDisabled) {
         this.dispatchEvent(new CustomEvent('show-alert', {
           bubbles: true,
+          composed: true,
           detail: {message: EMPTY_REPLY_MESSAGE},
         }));
         return;
@@ -743,6 +703,13 @@
       return this.send(this._includeComments, this.canBeStarted)
           .then(keepReviewers => {
             this._purgeReviewersPendingRemove(false, keepReviewers);
+          })
+          .catch(err => {
+            this.dispatchEvent(new CustomEvent('show-error', {
+              bubbles: true,
+              composed: true,
+              detail: {message: `Error submitting review ${err}`},
+            }));
           });
     },
 
@@ -763,7 +730,7 @@
 
     _confirmPendingReviewer() {
       if (this._ccPendingConfirmation) {
-        this.$$('#ccs').confirmGroup(this._ccPendingConfirmation.group);
+        this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
         this._focusOn(FocusTarget.CCS);
       } else {
         this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
@@ -831,7 +798,8 @@
 
     _reload() {
       // Load the current change without any patch range.
-      location.href = this.getBaseUrl() + '/c/' + this.change._number;
+      Gerrit.Nav.navigateToChange(this.change);
+      this.cancel();
     },
 
     _computeSendButtonLabel(canBeStarted) {
@@ -848,6 +816,19 @@
 
     _computeSendButtonDisabled(buttonLabel, drafts, text, reviewersMutated,
         labelsChanged, includeComments, disabled) {
+      // Polymer 2: check for undefined
+      if ([
+        buttonLabel,
+        drafts,
+        text,
+        reviewersMutated,
+        labelsChanged,
+        includeComments,
+        disabled,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (disabled) { return true; }
       if (buttonLabel === ButtonLabels.START_REVIEW) { return false; }
       const hasDrafts = includeComments && Object.keys(drafts).length;
@@ -869,5 +850,19 @@
     _sendDisabledChanged(sendDisabled) {
       this.dispatchEvent(new CustomEvent('send-disabled-changed'));
     },
+
+    _getReviewerSuggestionsProvider(change) {
+      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+      provider.init();
+      return provider;
+    },
+
+    _getCcSuggestionsProvider(change) {
+      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
+      provider.init();
+      return provider;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 5577c1b..d8d49cf 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-reply-dialog.html">
 
@@ -33,6 +35,25 @@
 </test-fixture>
 
 <script>
+  function cloneableResponse(status, text) {
+    return {
+      ok: false,
+      status,
+      text() {
+        return Promise.resolve(text);
+      },
+      clone() {
+        return {
+          ok: false,
+          status,
+          text() {
+            return Promise.resolve(text);
+          },
+        };
+      },
+    };
+  }
+
   suite('gr-reply-dialog tests', () => {
     let element;
     let changeNum;
@@ -256,17 +277,19 @@
       });
     });
 
-    test('setlabelValue', () => {
+    test('setlabelValue', done => {
       element._account = {_account_id: 1};
-      flushAsynchronousOperations();
-      const label = 'Verified';
-      const value = '+1';
-      element.setLabelValue(label, value);
-      flushAsynchronousOperations();
-      const labels = element.$.labelScores.getLabelValues();
-      assert.deepEqual(labels, {
-        'Code-Review': 0,
-        'Verified': 1,
+      flush(() => {
+        const label = 'Verified';
+        const value = '+1';
+        element.setLabelValue(label, value);
+
+        const labels = element.$.labelScores.getLabelValues();
+        assert.deepEqual(labels, {
+          'Code-Review': 0,
+          'Verified': 1,
+        });
+        done();
       });
     });
 
@@ -289,6 +312,26 @@
       });
     }
 
+    function isFocusInsideElement(element) {
+      // In Polymer 2 focused element either <paper-input> or nested
+      // native input <input> element depending on the current focus
+      // in browser window.
+      // For example, the focus is changed if the developer console
+      // get a focus.
+      let activeElement = getActiveElement();
+      while (activeElement) {
+        if (activeElement === element) {
+          return true;
+        }
+        if (activeElement.parentElement) {
+          activeElement = activeElement.parentElement;
+        } else {
+          activeElement = activeElement.getRootNode().host;
+        }
+      }
+      return false;
+    }
+
     function testConfirmationDialog(done, cc) {
       const yesButton =
           element.$$('.reviewerConfirmationButtons gr-button:first-child');
@@ -342,10 +385,11 @@
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
         // We should be focused on account entry input.
-        assert.equal(getActiveElement().id, 'input');
+        assert.isTrue(
+            isFocusInsideElement(element.$.reviewers.$.entry.$.input.$.input));
 
         // No reviewer/CC should have been added.
-        assert.equal(element.$$('#ccs').additions().length, 0);
+        assert.equal(element.$.ccs.additions().length, 0);
         assert.equal(element.$.reviewers.additions().length, 0);
 
         // Reopen confirmation dialog.
@@ -370,7 +414,7 @@
       }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
         const additions = cc ?
-          element.$$('#ccs').additions() :
+          element.$.ccs.additions() :
           element.$.reviewers.additions();
         assert.deepEqual(
             additions,
@@ -387,7 +431,13 @@
             ]);
 
         // We should be focused on account entry input.
-        assert.equal(getActiveElement().id, 'input');
+        if (cc) {
+          assert.isTrue(
+              isFocusInsideElement(element.$.ccs.$.entry.$.input.$.input));
+        } else {
+          assert.isTrue(
+              isFocusInsideElement(element.$.reviewers.$.entry.$.input.$.input));
+        }
       }).then(done);
     }
 
@@ -409,10 +459,10 @@
     test('_reviewersMutated when account-text-change is fired from ccs', () => {
       flushAsynchronousOperations();
       assert.isFalse(element._reviewersMutated);
-      assert.isTrue(element.$$('#ccs').allowAnyInput);
+      assert.isTrue(element.$.ccs.allowAnyInput);
       assert.isFalse(element.$$('#reviewers').allowAnyInput);
-      element.$$('#ccs').dispatchEvent(new CustomEvent('account-text-changed',
-          {bubbles: true}));
+      element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
+          {bubbles: true, composed: true}));
       assert.isTrue(element._reviewersMutated);
     });
 
@@ -471,11 +521,7 @@
       sandbox.stub(window, 'fetch', () => {
         const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
           '"ccs":{"id2":{"error":"second error"}}}';
-        return Promise.resolve({
-          ok: false,
-          status: 400,
-          text() { return Promise.resolve(text); },
-        });
+        return Promise.resolve(cloneableResponse(400, text));
       });
 
       element.addEventListener('server-error', event => {
@@ -493,6 +539,27 @@
       flush(() => { element.send(); });
     });
 
+    test('non-json 400 is treated as a normal server-error', done => {
+      sandbox.stub(window, 'fetch', () => {
+        const text = 'Comment validation error!';
+        return Promise.resolve(cloneableResponse(400, text));
+      });
+
+      element.addEventListener('server-error', event => {
+        if (event.target !== element) {
+          return;
+        }
+        event.detail.response.text().then(body => {
+          assert.equal(body, 'Comment validation error!');
+          done();
+        });
+      });
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      flush(() => { element.send(); });
+    });
+
     test('filterReviewerSuggestion', () => {
       const owner = makeAccount();
       const reviewer1 = makeAccount();
@@ -528,7 +595,7 @@
       const textareaStub = sandbox.stub(element.$.textarea, 'async');
       const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
           'async');
-      const ccStub = sandbox.stub(element.$$('#ccs').focusStart, 'async');
+      const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
       element._focusOn();
       assert.equal(element._chooseFocusTarget.callCount, 1);
       assert.deepEqual(textareaStub.callCount, 1);
@@ -695,6 +762,40 @@
       assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
     });
 
+    test('moving from reviewer to cc', () => {
+      element._reviewersPendingRemove = {
+        CC: [],
+        REVIEWER: [],
+      };
+      flushAsynchronousOperations();
+
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeAccount();
+      const reviewer3 = makeAccount();
+      const cc1 = makeAccount();
+      const cc2 = makeAccount();
+      const cc3 = makeAccount();
+      const cc4 = makeAccount();
+      element._reviewers = [reviewer1, reviewer2, reviewer3];
+      element._ccs = [cc1, cc2, cc3, cc4];
+      element.push('_ccs', reviewer1);
+      flushAsynchronousOperations();
+
+      assert.deepEqual(element._reviewers,
+          [reviewer2, reviewer3]);
+      assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+      assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
+
+      element.push('_ccs', reviewer3, reviewer2);
+      flushAsynchronousOperations();
+
+      assert.deepEqual(element._reviewers, []);
+      assert.deepEqual(element._ccs,
+          [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
+      assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
+          [reviewer1, reviewer3, reviewer2]);
+    });
+
     test('migrate reviewers between states', done => {
       element._reviewersPendingRemove = {
         CC: [],
@@ -702,7 +803,7 @@
       };
       flushAsynchronousOperations();
       const reviewers = element.$.reviewers;
-      const ccs = element.$$('#ccs');
+      const ccs = element.$.ccs;
       const reviewer1 = makeAccount();
       const reviewer2 = makeAccount();
       const cc1 = makeAccount();
@@ -801,60 +902,50 @@
       const error1 = 'error 1';
       const error2 = 'error 2';
       const error3 = 'error 3';
-      const response = {
-        status: 400,
-        text() {
-          return Promise.resolve(')]}\'' + JSON.stringify({
-            reviewers: {
-              username1: {
-                input: 'user 1',
-                error: error1,
-              },
-              username2: {
-                input: 'user 2',
-                error: error2,
-              },
-            },
-            ccs: {
-              username3: {
-                input: 'user 3',
-                error: error3,
-              },
-            },
-          }));
+      const text = ')]}\'' + JSON.stringify({
+        reviewers: {
+          username1: {
+            input: 'user 1',
+            error: error1,
+          },
+          username2: {
+            input: 'user 2',
+            error: error2,
+          },
         },
-      };
+        ccs: {
+          username3: {
+            input: 'user 3',
+            error: error3,
+          },
+        },
+      });
       element.addEventListener('server-error', e => {
         e.detail.response.text().then(text => {
           assert.equal(text, [error1, error2, error3].join(', '));
           done();
         });
       });
-      element._handle400Error(response);
+      element._handle400Error(cloneableResponse(400, text));
     });
 
     test('_handle400Error CCs only', done => {
       const error1 = 'error 1';
-      const response = {
-        status: 400,
-        text() {
-          return Promise.resolve(')]}\'' + JSON.stringify({
-            ccs: {
-              username1: {
-                input: 'user 1',
-                error: error1,
-              },
-            },
-          }));
+      const text = ')]}\'' + JSON.stringify({
+        ccs: {
+          username1: {
+            input: 'user 1',
+            error: error1,
+          },
         },
-      };
+      });
       element.addEventListener('server-error', e => {
         e.detail.response.text().then(text => {
           assert.equal(text, error1);
           done();
         });
       });
-      element._handle400Error(response);
+      element._handle400Error(cloneableResponse(400, text));
     });
 
     test('fires height change when the drafts load', done => {
@@ -898,21 +989,6 @@
           assert.isFalse(startReviewStub.called);
         });
       });
-
-      test('fall back to start review against old backend', () => {
-        stubSaveReview(review => {
-          return {}; // old backend won't set ready: true
-        });
-
-        return element.send(true, true).then(() => {
-          assert.isTrue(startReviewStub.called);
-        }).then(() => {
-          startReviewStub.reset();
-          return element.send(true, false);
-        }).then(() => {
-          assert.isFalse(startReviewStub.called);
-        });
-      });
     });
 
     suite('start review and save buttons', () => {
@@ -938,28 +1014,9 @@
       });
     });
 
-    test('dummy message to force email on start review', () => {
-      stubSaveReview(review => {
-        assert.equal(review.message, element.START_REVIEW_MESSAGE);
-        return {ready: true};
-      });
-      return element.send(true, true);
-    });
-
     test('buttons disabled until all API calls are resolved', () => {
       stubSaveReview(review => {
-        return {}; // old backend won't set ready: true
-      });
-      // Check that element is disabled asynchronously after the setReady
-      // promise is returned. The element should not be reenabled until
-      // that promise is resolved.
-      sandbox.stub(element, '_maybeSetReady', (startReview, response) => {
-        return new Promise(resolve => {
-          Polymer.Base.async(() => {
-            assert.isTrue(element.disabled);
-            resolve();
-          });
-        });
+        return {ready: true};
       });
       return element.send(true, true).then(() => {
         assert.isFalse(element.disabled);
@@ -979,11 +1036,6 @@
         assert.isFalse(element.disabled);
       }
 
-      function assertDialogClosed() {
-        assert.strictEqual('', element.draft);
-        assert.isFalse(element.disabled);
-      }
-
       test('error occurs in _saveReview', () => {
         stubSaveReview(review => {
           throw expectedError;
@@ -994,46 +1046,6 @@
         });
       });
 
-      test('error occurs during startReview', () => {
-        stubSaveReview(review => {
-          return {}; // old backend won't set ready: true
-        });
-        const errorStub = sandbox.stub(
-            console, 'error', (msg, err) => undefined);
-        sandbox.stub(element.$.restAPI, 'startReview', () => {
-          throw expectedError;
-        });
-        return element.send(true, true).then(() => {
-          assertDialogClosed();
-          assert.isTrue(
-              errorStub.calledWith('error setting ready:', expectedError));
-        });
-      });
-
-      test('non-ok response received by startReview', () => {
-        stubSaveReview(review => {
-          return {}; // old backend won't set ready: true
-        });
-        sandbox.stub(element.$.restAPI, 'startReview', (c, b, f) => {
-          f({status: 500});
-        });
-        return element.send(true, true).then(() => {
-          assertDialogClosed();
-        });
-      });
-
-      test('409 response received by startReview', () => {
-        stubSaveReview(review => {
-          return {}; // old backend won't set ready: true
-        });
-        sandbox.stub(element.$.restAPI, 'startReview', (c, b, f) => {
-          f({status: 409});
-        });
-        return element.send(true, true).then(() => {
-          assertDialogClosed();
-        });
-      });
-
       suite('pending diff drafts?', () => {
         test('yes', () => {
           const promise = mockPromise();
@@ -1065,20 +1077,84 @@
 
     test('_computeSendButtonDisabled', () => {
       const fn = element._computeSendButtonDisabled.bind(element);
-      assert.isFalse(fn('Start review'));
-      assert.isTrue(fn('Send', {}, '', false, false, false));
+      assert.isFalse(fn(
+          /* buttonLabel= */ 'Start review',
+          /* drafts= */ {},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ false,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
+      assert.isTrue(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ false,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
       // Mock nonempty comment draft array, with seding comments.
-      assert.isFalse(fn('Send', {file: ['draft']}, '', false, false, true));
+      assert.isFalse(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {file: ['draft']},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ false,
+          /* includeComments= */ true,
+          /* disabled= */ false
+      ));
       // Mock nonempty comment draft array, without seding comments.
-      assert.isTrue(fn('Send', {file: ['draft']}, '', false, false, false));
+      assert.isTrue(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {file: ['draft']},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ false,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
       // Mock nonempty change message.
-      assert.isFalse(fn('Send', {}, 'test', false, false, false));
+      assert.isFalse(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {},
+          /* text= */ 'test',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ false,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
       // Mock reviewers mutated.
-      assert.isFalse(fn('Send', {}, '', true, false, false));
+      assert.isFalse(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {},
+          /* text= */ '',
+          /* reviewersMutated= */ true,
+          /* labelsChanged= */ false,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
       // Mock labels changed.
-      assert.isFalse(fn('Send', {}, '', false, true, false));
+      assert.isFalse(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ true,
+          /* includeComments= */ false,
+          /* disabled= */ false
+      ));
       // Whole dialog is disabled.
-      assert.isTrue(fn('Send', {}, '', false, true, false, true));
+      assert.isTrue(fn(
+          /* buttonLabel= */ 'Send',
+          /* drafts= */ {},
+          /* text= */ '',
+          /* reviewersMutated= */ false,
+          /* labelsChanged= */ true,
+          /* includeComments= */ false,
+          /* disabled= */ true
+      ));
     });
 
     test('_submit blocked when no mutations exist', () => {
@@ -1092,7 +1168,7 @@
       MockInteractions.tap(element.$$('gr-button.send'));
       assert.isFalse(sendStub.called);
 
-      element.diffDrafts = {test: true};
+      element.diffDrafts = {test: [{val: true}]};
       flushAsynchronousOperations();
 
       MockInteractions.tap(element.$$('gr-button.send'));
@@ -1104,7 +1180,7 @@
       // computed to false.
       element.diffDrafts = {};
       assert.equal(element.getFocusStops().end, element.$.cancelButton);
-      element.diffDrafts = {test: true};
+      element.diffDrafts = {test: [{val: true}]};
       assert.equal(element.getFocusStops().end, element.$.sendButton);
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 73e8bea..a5875ab1 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<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="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -32,53 +32,35 @@
         opacity: .8;
         pointer-events: none;
       }
-      .autocompleteContainer {
-        position: relative;
-      }
-      .hiddenReviewers {
-        margin-top: .3em;
-      }
-      .inputContainer {
-        display: flex;
-        margin-top: .25em;
-      }
-      .inputContainer input {
-        flex: 1;
-        font: inherit;
-      }
-      gr-account-chip {
-        margin-top: .3em;
+      .container > :not(:first-child) {
+        margin-top: var(--spacing-s);
       }
       gr-button {
         --gr-button: {
-          padding-left: 0;
-          padding-right: 0;
-        }
-      }
-      @media screen and (max-width: 50em), screen and (min-width: 75em) {
-        gr-account-chip:first-of-type {
-          margin-top: 0;
+          padding: 0px 0px;
         }
       }
     </style>
-    <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-      <gr-account-chip class="reviewer" account="[[reviewer]]"
-          on-remove="_handleRemove"
-          additional-text="[[_computeReviewerTooltip(reviewer, change)]]"
-          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
-      </gr-account-chip>
-    </template>
-    <gr-button
-        class="hiddenReviewers"
-        link
-        hidden$="[[!_hiddenReviewerCount]]"
-        on-tap="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
-    <div class="controlsContainer" hidden$="[[!mutable]]">
+    <div class="container">
+      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
+        <gr-account-chip class="reviewer" account="[[reviewer]]"
+            on-remove="_handleRemove"
+            additional-text="[[_computeReviewerTooltip(reviewer, change)]]"
+            removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
+        </gr-account-chip>
+      </template>
       <gr-button
+          class="hiddenReviewers"
           link
-          id="addReviewer"
-          class="addReviewer"
-          on-tap="_handleAddTap">[[_addLabel]]</gr-button>
+          hidden$="[[!_hiddenReviewerCount]]"
+          on-click="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
+      <div class="controlsContainer" hidden$="[[!mutable]]">
+        <gr-button
+            link
+            id="addReviewer"
+            class="addReviewer"
+            on-click="_handleAddTap">[[_addLabel]]</gr-button>
+      </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 4f1b051..c94625d 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-reviewer-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the "Add reviewer..." button is tapped.
@@ -75,6 +74,10 @@
       _xhrPromise: Object,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
@@ -163,6 +166,11 @@
     },
 
     _reviewersChanged(changeRecord, owner) {
+      // Polymer 2: check for undefined
+      if ([changeRecord, owner].some(arg => arg === undefined)) {
+        return;
+      }
+
       let result = [];
       const reviewers = changeRecord.base;
       for (const key in reviewers) {
@@ -180,10 +188,10 @@
         return reviewer._account_id != owner._account_id;
       });
 
-      // If there is one more than the max reviewers, don't show the 'show
-      // more' button, because it takes up just as much space.
+      // If there is one or two more than the max reviewers, don't show the
+      // 'show more' button, because it takes up just as much space.
       if (this.maxReviewersDisplayed &&
-          this._reviewers.length > this.maxReviewersDisplayed + 1) {
+          this._reviewers.length > this.maxReviewersDisplayed + 2) {
         this._displayedReviewers =
           this._reviewers.slice(0, this.maxReviewersDisplayed);
       } else {
@@ -192,6 +200,11 @@
     },
 
     _computeHiddenCount(reviewers, displayedReviewers) {
+      // Polymer 2: check for undefined
+      if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return reviewers.length - displayedReviewers.length;
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 81827fd..6936a04 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-reviewer-list.html">
 
@@ -212,10 +214,10 @@
       assert.isTrue(element.$$('.hiddenReviewers').hidden);
     });
 
-    test('show all reviewers button with 7 reviewers', () => {
+    test('show all reviewers button with 8 reviewers', () => {
       const reviewers = [];
       element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 7; i++) {
+      for (let i = 0; i < 8; i++) {
         reviewers.push(
             {email: i+'reviewer@google.com', name: 'reviewer-' + i});
       }
@@ -229,9 +231,9 @@
           CC: reviewers,
         },
       };
-      assert.equal(element._hiddenReviewerCount, 2);
+      assert.equal(element._hiddenReviewerCount, 3);
       assert.equal(element._displayedReviewers.length, 5);
-      assert.equal(element._reviewers.length, 7);
+      assert.equal(element._reviewers.length, 8);
       assert.isFalse(element.$$('.hiddenReviewers').hidden);
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index 4d8e5ae..4c82d2a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
 
@@ -26,11 +26,11 @@
       #threads {
         display: block;
         min-height: 20rem;
-        padding: 1rem;
+        padding: var(--spacing-l);
       }
       gr-comment-thread {
         display: block;
-        margin-bottom: .5rem;
+        margin-bottom: var(--spacing-m);
         max-width: 80ch;
       }
       .header {
@@ -41,7 +41,7 @@
         display: flex;
         justify-content: left;
         min-height: 3.2em;
-        padding: .5em var(--default-horizontal-margin);
+        padding: var(--spacing-m) var(--spacing-l);
       }
       .toggleItem.draftToggle {
         display: none;
@@ -52,7 +52,7 @@
       .toggleItem {
         align-items: center;
         display: flex;
-        margin-right: 1rem;
+        margin-right: var(--spacing-l);
       }
       .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
       .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 317685b..747a47a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -25,7 +25,6 @@
 
   Polymer({
     is: 'gr-thread-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
@@ -95,12 +94,35 @@
     },
 
     _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly) {
+      // Polymer 2: check for undefined
+      if ([
+        sortedThreads,
+        unresolvedOnly,
+        draftsOnly,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return sortedThreads.filter(c => {
         if (draftsOnly) {
           return c.hasDraft;
         } else if (unresolvedOnly) {
           return c.unresolved;
         } else {
+          const comments = c && c.thread && c.thread.comments;
+          let robotComment = false;
+          let humanReplyToRobotComment = false;
+          comments.forEach(comment => {
+            if (comment.robot_id) {
+              robotComment = true;
+            } else if (robotComment) {
+              // Robot comment exists and human comment exists after it
+              humanReplyToRobotComment = true;
+            }
+          });
+          if (robotComment) {
+            return humanReplyToRobotComment ? c : false;
+          }
           return c;
         }
       }).map(threadInfo => threadInfo.thread);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 792644e..ff65aa8 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-thread-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-thread-list.html">
 
@@ -168,6 +170,68 @@
           rootId: 'zcf0b9fa_fe1a5f62',
           start_datetime: '2018-02-09 18:49:18.000000000',
         },
+        {
+          comments: [
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 4,
+              id: 'rc1',
+              line: 5,
+              updated: '2019-02-08 18:49:18.000000000',
+              message: 'test',
+              unresolved: true,
+              robot_id: 'rc1',
+            },
+          ],
+          patchNum: 4,
+          path: '/COMMIT_MSG',
+          line: 5,
+          rootId: 'rc1',
+          start_datetime: '2019-02-08 18:49:18.000000000',
+        },
+        {
+          comments: [
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 4,
+              id: 'rc2',
+              line: 5,
+              updated: '2019-03-08 18:49:18.000000000',
+              message: 'test',
+              unresolved: true,
+              robot_id: 'rc2',
+            },
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 4,
+              id: 'c2_1',
+              line: 5,
+              updated: '2019-03-08 18:49:18.000000000',
+              message: 'test',
+              unresolved: true,
+            },
+          ],
+          patchNum: 4,
+          path: '/COMMIT_MSG',
+          line: 5,
+          rootId: 'rc2',
+          start_datetime: '2019-03-08 18:49:18.000000000',
+        },
       ];
       flushAsynchronousOperations();
       threadElements = Polymer.dom(element.root)
@@ -192,46 +256,69 @@
     });
 
     test('_computeSortedThreads', () => {
-      assert.equal(element._sortedThreads.length, 5);
+      assert.equal(element._sortedThreads.length, 7);
       // Draft and unresolved
       assert.equal(element._sortedThreads[0].thread.rootId,
           'ecf0b9fa_fe1a5f62');
-      // unresolved
+      // Unresolved robot comment
       assert.equal(element._sortedThreads[1].thread.rootId,
+          'rc2');
+      // Unresolved robot comment
+      assert.equal(element._sortedThreads[2].thread.rootId,
+          'rc1');
+      // unresolved
+      assert.equal(element._sortedThreads[3].thread.rootId,
           'scaddf38_44770ec1');
       // unresolved
-      assert.equal(element._sortedThreads[2].thread.rootId,
+      assert.equal(element._sortedThreads[4].thread.rootId,
           '8caddf38_44770ec1');
       // resolved and draft
-      assert.equal(element._sortedThreads[3].thread.rootId,
+      assert.equal(element._sortedThreads[5].thread.rootId,
           'zcf0b9fa_fe1a5f62');
       // resolved
-      assert.equal(element._sortedThreads[4].thread.rootId,
+      assert.equal(element._sortedThreads[6].thread.rootId,
           '09a9fb0a_1484e6cf');
     });
 
+    test('filtered threads do not contain robot comments without reply', () => {
+      const thread = element.threads.find(thread => thread.rootId === 'rc1');
+      assert.equal(element._filteredThreads.includes(thread), false);
+    });
+
+    test('filtered threads contains robot comments with reply', () => {
+      const thread = element.threads.find(thread => thread.rootId === 'rc2');
+      assert.equal(element._filteredThreads.includes(thread), true);
+    });
+
+
     test('thread removal', () => {
-      threadElements[1].fire('thread-discard', {rootId: 'scaddf38_44770ec1'});
+      threadElements[1].fire('thread-discard', {rootId: 'rc2'});
       flushAsynchronousOperations();
-      assert.equal(element._sortedThreads.length, 4);
+      assert.equal(element._sortedThreads.length, 6);
       assert.equal(element._sortedThreads[0].thread.rootId,
           'ecf0b9fa_fe1a5f62');
-      // unresolved
+      // Unresolved robot comment
       assert.equal(element._sortedThreads[1].thread.rootId,
+          'rc1');
+      // unresolved
+      assert.equal(element._sortedThreads[2].thread.rootId,
+          'scaddf38_44770ec1');
+      // unresolved
+      assert.equal(element._sortedThreads[3].thread.rootId,
           '8caddf38_44770ec1');
       // resolved and draft
-      assert.equal(element._sortedThreads[2].thread.rootId,
+      assert.equal(element._sortedThreads[4].thread.rootId,
           'zcf0b9fa_fe1a5f62');
       // resolved
-      assert.equal(element._sortedThreads[3].thread.rootId,
+      assert.equal(element._sortedThreads[5].thread.rootId,
           '09a9fb0a_1484e6cf');
     });
 
-    test('toggle unresolved only shows unressolved comments', () => {
+    test('toggle unresolved only shows unresolved comments', () => {
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 3);
+          .querySelectorAll('gr-comment-thread').length, 5);
     });
 
     test('toggle drafts only shows threads with draft comments', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
index 792c300..e3cee56 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
@@ -32,11 +33,11 @@
         width: 100%;
       }
       ol {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
         list-style: decimal;
       }
       p {
-        margin-bottom: .75em;
+        margin-bottom: var(--spacing-m);
       }
     </style>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index df96be2..092204a 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -29,7 +29,6 @@
 
   Polymer({
     is: 'gr-upload-help-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -58,6 +57,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     attached() {
       this.$.restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
@@ -73,11 +76,21 @@
 
     _handleCloseTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('close', null, {bubbles: false});
     },
 
     _computeFetchCommand(revision, preferredDownloadCommand,
         preferredDownloadScheme) {
+      // Polymer 2: check for undefined
+      if ([
+        revision,
+        preferredDownloadCommand,
+        preferredDownloadScheme,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (!revision) { return; }
       if (!revision || !revision.fetch) { return; }
 
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
index a5a6e76..577b978 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-upload-help-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-upload-help-dialog.html">
 
@@ -73,31 +75,40 @@
         assert.isUndefined(element._computeFetchCommand({fetch: {}}));
       });
 
+      test('not all defined', () => {
+        assert.isUndefined(
+            element._computeFetchCommand(testRev, undefined, ''));
+        assert.isUndefined(
+            element._computeFetchCommand(testRev, '', undefined));
+        assert.isUndefined(
+            element._computeFetchCommand(undefined, '', ''));
+      });
+
       test('insufficiently defined scheme', () => {
         assert.isUndefined(
-            element._computeFetchCommand(testRev, undefined, 'badscheme'));
+            element._computeFetchCommand(testRev, '', 'badscheme'));
 
         const rev = Object.assign({}, testRev);
         rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
         assert.isUndefined(
-            element._computeFetchCommand(rev, undefined, 'nocmds'));
+            element._computeFetchCommand(rev, '', 'nocmds'));
 
         rev.fetch.nocmds.commands.unsupported = 'unsupported';
         assert.isUndefined(
-            element._computeFetchCommand(rev, undefined, 'nocmds'));
+            element._computeFetchCommand(rev, '', 'nocmds'));
       });
 
       test('default scheme and command', () => {
-        const cmd = element._computeFetchCommand(testRev);
+        const cmd = element._computeFetchCommand(testRev, '', '');
         assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
       });
 
       test('default command', () => {
         assert.strictEqual(
-            element._computeFetchCommand(testRev, undefined, 'http'),
+            element._computeFetchCommand(testRev, '', 'http'),
             'http checkout');
         assert.strictEqual(
-            element._computeFetchCommand(testRev, undefined, 'ssh'),
+            element._computeFetchCommand(testRev, '', 'ssh'),
             'ssh pull');
       });
 
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 2c2fb4e..5152ef9 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,18 +15,19 @@
 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/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">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 
 <dom-module id="gr-account-dropdown">
   <template>
     <style include="shared-styles">
       gr-dropdown {
-        padding: 0 .5em;
+        padding: 0 var(--spacing-m);
         --gr-button: {
           color: var(--header-text-color);
         }
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 af4510d..7cbe988 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
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-account-dropdown',
-    _legacyUndefinedCheck: true,
 
     properties: {
       account: Object,
@@ -58,7 +57,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
     ],
 
     detached() {
@@ -66,6 +65,11 @@
     },
 
     _getLinks(switchAccountUrl, path) {
+      // Polymer 2: check for undefined
+      if ([switchAccountUrl, path].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const links = [{name: 'Settings', url: '/settings/'}];
       if (switchAccountUrl) {
         const replacements = {path};
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index fe63a3e..e29faa8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-dropdown</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-account-dropdown.html">
 
@@ -78,11 +80,15 @@
     });
 
     test('switch account', () => {
+      // Missing params.
+      assert.isUndefined(element._getLinks());
+      assert.isUndefined(element._getLinks(null));
+
       // No switch account link.
-      assert.equal(element._getLinks(null).length, 2);
+      assert.equal(element._getLinks(null, '').length, 2);
 
       // Unparameterized switch account link.
-      let links = element._getLinks('/switch-account');
+      let links = element._getLinks('/switch-account', '');
       assert.equal(links.length, 3);
       assert.deepEqual(links[1], {
         name: 'Switch account',
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
index f8bf33c..09b928e 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
index 5679408..8d3b58e 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-error-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the dismiss button is pressed.
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
index e2c314b..648f8be 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-error-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 4ca106e..048d392 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -16,7 +16,8 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-alert/gr-alert.html">
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 38dfecb..5865e3c 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -27,10 +27,10 @@
 
   Polymer({
     is: 'gr-error-manager',
-    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
     ],
 
     properties: {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 88e3efd..9140c17 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-manager</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-error-manager.html">
 
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
index 2ff7953..a863276 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-key-binding-display">
@@ -24,10 +24,10 @@
       .key {
         background-color: var(--chip-background-color);
         border: 1px solid var(--border-color);
-        border-radius: 3px;
+        border-radius: var(--border-radius);
         display: inline-block;
         font-weight: var(--font-weight-bold);
-        padding: .1em .5em;
+        padding: var(--spacing-xxs) var(--spacing-m);
         text-align: center;
       }
     </style>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
index e8c6479..89d1091 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-key-binding-display',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {Array<string>} */
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
index 0361d76..39c8af8 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-key-binding-display.html">
 
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index e3552cc..5494f62 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html">
@@ -30,11 +31,11 @@
         overflow-y: auto;
       }
       header{
-        padding: 1em;
+        padding: var(--spacing-l);
       }
       main {
         display: flex;
-        padding: 0 2em 2em;
+        padding: 0 var(--spacing-xxl) var(--spacing-xxl);
       }
       header {
         align-items: center;
@@ -43,18 +44,18 @@
         justify-content: space-between;
       }
       table:last-of-type {
-        margin-left: 3em;
+        margin-left: var(--spacing-xxl);
       }
       td {
-        padding: .2em 0;
+        padding: var(--spacing-xs) 0;
       }
       td:first-child {
-        padding-right: .5em;
+        padding-right: var(--spacing-m);
         text-align: right;
       }
       .header {
         font-weight: var(--font-weight-bold);
-        padding-top: 1em;
+        padding-top: var(--spacing-l);
       }
       .modifier {
         font-weight: normal;
@@ -62,7 +63,7 @@
     </style>
     <header>
       <h3>Keyboard shortcuts</h3>
-      <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+      <gr-button link on-click="_handleCloseTap">Close</gr-button>
     </header>
     <main>
       <table>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index f206db1..4bc6e11 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-keyboard-shortcuts-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -51,6 +50,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -70,6 +70,7 @@
 
     _handleCloseTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('close', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
index 50579dd..1a3d6c7 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-keyboard-shortcuts-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index e552cda..d29858e 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -14,10 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
@@ -45,7 +46,6 @@
       .bigTitle:hover {
         text-decoration: underline;
       }
-      /* TODO (viktard): Clean-up after chromium-style migrates to component. */
       .titleText::before {
         background-image: var(--header-icon);
         background-size: var(--header-icon-size) var(--header-icon-size);
@@ -62,7 +62,7 @@
       }
       ul {
         list-style: none;
-        padding-left: 1em;
+        padding-left: var(--spacing-l);
       }
       .links > li {
         cursor: default;
@@ -86,16 +86,16 @@
         justify-content: flex-end;
       }
       .rightItems gr-endpoint-decorator:not(:empty) {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       gr-smart-search {
         flex-grow: 1;
-        margin-left: .5em;
+        margin: 0 var(--spacing-m);
         max-width: 500px;
       }
       gr-dropdown,
       .browse {
-        padding: .6em .5em;
+        padding: var(--spacing-m);
       }
       gr-dropdown {
         --gr-dropdown-item: {
@@ -103,7 +103,7 @@
         }
       }
       .settingsButton {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       .browse {
         color: var(--header-text-color);
@@ -128,13 +128,13 @@
       .accountContainer {
         align-items: center;
         display: flex;
-        margin: 0 -.5em 0 .5em;
+        margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
       }
       .loginButton, .registerButton {
-        padding: .5em 1em;
+        padding: var(--spacing-m) var(--spacing-l);
       }
       .dropdown-trigger {
         text-decoration: none;
@@ -160,7 +160,7 @@
       }
       @media screen and (max-width: 50em) {
         .bigTitle {
-          font-size: var(--font-size-large);
+          font-size: var(--font-size-h3);
           font-weight: var(--font-weight-bold);
         }
         gr-smart-search,
@@ -173,10 +173,10 @@
           display: inline-flex;
         }
         .accountContainer {
-          margin-left: .5em !important;
+          margin-left: var(--spacing-m) !important;
         }
         gr-dropdown {
-          padding: .5em 0 .5em .5em;
+          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
         }
       }
     </style>
@@ -188,16 +188,16 @@
       </a>
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-          <li class$="[[linkGroup.class]]">
-          <gr-dropdown
-              link
-              down-arrow
-              items = [[linkGroup.links]]
-              horizontal-align="left">
-            <span class="linksTitle" id="[[linkGroup.title]]">
-              [[linkGroup.title]]
-            </span>
-          </gr-dropdown>
+          <li class$="[[_computeLinkGroupClass(linkGroup)]]">
+            <gr-dropdown
+                link
+                down-arrow
+                items = [[linkGroup.links]]
+                horizontal-align="left">
+              <span class="linksTitle" id="[[linkGroup.title]]">
+                [[linkGroup.title]]
+              </span>
+            </gr-dropdown>
           </li>
         </template>
       </ul>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 69fc89f..773ad68 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -71,7 +71,6 @@
 
   Polymer({
     is: 'gr-main-header',
-    _legacyUndefinedCheck: true,
 
     hostAttributes: {
       role: 'banner',
@@ -138,6 +137,7 @@
       Gerrit.AdminNavBehavior,
       Gerrit.BaseUrlBehavior,
       Gerrit.DocsUrlBehavior,
+      Gerrit.FireBehavior,
     ],
 
     observers: [
@@ -180,6 +180,17 @@
     },
 
     _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
+      // Polymer 2: check for undefined
+      if ([
+        defaultLinks,
+        userLinks,
+        adminLinks,
+        topMenus,
+        docBaseUrl,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const links = defaultLinks.map(menu => {
         return {
           title: menu.title,
@@ -318,7 +329,16 @@
 
     _onMobileSearchTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('mobile-search', null, {bubbles: false});
     },
+
+    _computeLinkGroupClass(linkGroup) {
+      if (linkGroup && linkGroup.class) {
+        return linkGroup.class;
+      }
+
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 89b3908..3309aa5 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-main-header</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-main-header.html">
 
@@ -109,22 +111,34 @@
       }];
 
       // When no admin links are passed, it should use the default.
-      assert.deepEqual(element._computeLinks(defaultLinks, [], adminLinks, []),
-          defaultLinks.concat({
-            title: 'Browse',
-            links: adminLinks,
-          }));
-      assert.deepEqual(
-          element._computeLinks(defaultLinks, userLinks, adminLinks, []),
-          defaultLinks.concat([
-            {
-              title: 'Your',
-              links: userLinks,
-            },
-            {
-              title: 'Browse',
-              links: adminLinks,
-            }]));
+      assert.deepEqual(element._computeLinks(
+          defaultLinks,
+          /* userLinks= */[],
+          adminLinks,
+          /* topMenus= */[],
+          /* docBaseUrl= */ ''
+      ),
+      defaultLinks.concat({
+        title: 'Browse',
+        links: adminLinks,
+      }));
+      assert.deepEqual(element._computeLinks(
+          defaultLinks,
+          userLinks,
+          adminLinks,
+          /* topMenus= */[],
+          /* docBaseUrl= */ ''
+      ),
+      defaultLinks.concat([
+        {
+          title: 'Your',
+          links: userLinks,
+        },
+        {
+          title: 'Browse',
+          links: adminLinks,
+        }])
+      );
     });
 
     test('documentation links', () => {
@@ -166,7 +180,13 @@
           url: 'https://gerrit/plugins/plugin-manager/static/index.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          /* defaultLinks= */ [],
+          /* userLinks= */ [],
+          adminLinks,
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Browse',
         links: adminLinks,
       },
@@ -196,7 +216,13 @@
           url: '/plugins/myplugin/index.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          /* defaultLinks= */ [],
+          /* userLinks= */ [],
+          adminLinks,
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Browse',
         links: adminLinks,
       },
@@ -229,7 +255,13 @@
           url: 'https://gerrit/plugins/plugin-manager/static/create.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          /* defaultLinks= */ [],
+          /* userLinks= */ [],
+          adminLinks,
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Browse',
         links: adminLinks,
       }, {
@@ -260,7 +292,13 @@
           url: 'https://gerrit/plugins/plugin-manager/static/index.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks(defaultLinks, [], [], topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          defaultLinks,
+          /* userLinks= */ [],
+          /* adminLinks= */ [],
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Faves',
         links: defaultLinks[0].links.concat([{
           name: 'Manage',
@@ -285,7 +323,13 @@
           url: 'https://gerrit/plugins/plugin-manager/static/index.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks([], userLinks, [], topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          /* defaultLinks= */ [],
+          userLinks,
+          /* adminLinks= */ [],
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Your',
         links: userLinks.concat([{
           name: 'Manage',
@@ -310,7 +354,13 @@
           url: 'https://gerrit/plugins/plugin-manager/static/index.html',
         }],
       }];
-      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+      assert.deepEqual(element._computeLinks(
+          /* defaultLinks= */ [],
+          /* userLinks= */ [],
+          adminLinks,
+          topMenus,
+          /* baseDocUrl= */ ''
+      ), [{
         title: 'Browse',
         links: adminLinks.concat([{
           name: 'Manage',
@@ -348,4 +398,4 @@
       assert.equal(element._registerText, 'Sign up');
     });
   });
-</script>
+      </script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index 0d620bd..e79277a 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -115,6 +115,7 @@
         query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
             'is:open -is:ignored',
         hideIfEmpty: true,
+        suffixForDashboard: 'limit:25',
       },
       {
         // WIP open changes owned by viewing user. This section is omitted when
@@ -123,6 +124,7 @@
         query: 'is:open owner:${user} is:wip',
         selfOnly: true,
         hideIfEmpty: true,
+        suffixForDashboard: 'limit:25',
       },
       {
         // Non-WIP open changes owned by viewed user. Filter out changes ignored
@@ -130,6 +132,7 @@
         name: 'Outgoing reviews',
         query: 'is:open owner:${user} -is:wip -is:ignored',
         isOutgoing: true,
+        suffixForDashboard: 'limit:25',
       },
       {
         // Non-WIP open changes not owned by the viewed user, that the viewed user
@@ -138,12 +141,14 @@
         name: 'Incoming reviews',
         query: 'is:open -owner:${user} -is:wip -is:ignored ' +
             '(reviewer:${user} OR assignee:${user})',
+        suffixForDashboard: 'limit:25',
       },
       {
         // Open changes the viewed user is CCed on. Changes ignored by the viewing
         // user are filtered out.
         name: 'CCed on',
         query: 'is:open -is:ignored cc:${user}',
+        suffixForDashboard: 'limit:10',
       },
       {
         name: 'Recently closed',
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
index 2f72338..73ce86a 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-navigation</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
deleted file mode 100644
index 28c46f4..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
+++ /dev/null
@@ -1,61 +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';
-
-  const JANK_SLEEP_TIME_MS = 1000;
-
-  const GrJankDetector = {
-    // Slowdowns counter.
-    jank: 0,
-    fps: 0,
-    _lastFrameTime: 0,
-
-    start() {
-      this._requestAnimationFrame(this._detect.bind(this));
-    },
-
-    _requestAnimationFrame(callback) {
-      window.requestAnimationFrame(callback);
-    },
-
-    _detect(now) {
-      if (this._lastFrameTime === 0) {
-        this._lastFrameTime = now;
-        this.fps = 0;
-        this._requestAnimationFrame(this._detect.bind(this));
-        return;
-      }
-      const fpsNow = 1000/(now - this._lastFrameTime);
-      this._lastFrameTime = now;
-      // Calculate moving average within last 3 measurements.
-      this.fps = this.fps === 0 ? fpsNow : ((this.fps * 2 + fpsNow) / 3);
-      if (this.fps > 10) {
-        this._requestAnimationFrame(this._detect.bind(this));
-      } else {
-        this.jank++;
-        console.warn('JANK', this.jank);
-        this._lastFrameTime = 0;
-        window.setTimeout(
-            () => this._requestAnimationFrame(this._detect.bind(this)),
-            JANK_SLEEP_TIME_MS);
-      }
-    },
-  };
-
-  window.GrJankDetector = GrJankDetector;
-})();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
deleted file mode 100644
index 6faeec1..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-jank-detector</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-
-<script src="gr-jank-detector.js"></script>
-
-<script>
-  suite('gr-jank-detector tests', () => {
-    let sandbox;
-    let clock;
-    let instance;
-
-    const NOW_TIME = 100;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      clock = sinon.useFakeTimers(NOW_TIME);
-      instance = GrJankDetector;
-      instance._lastFrameTime = 0;
-      sandbox.stub(instance, '_requestAnimationFrame');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('start() installs frame callback', () => {
-      sandbox.stub(instance, '_detect');
-      instance._requestAnimationFrame.callsArg(0);
-      instance.start();
-      assert.isTrue(instance._detect.calledOnce);
-    });
-
-    test('measures fps', () => {
-      instance._detect(10);
-      instance._detect(30);
-      assert.equal(instance.fps, 50);
-    });
-
-    test('detects jank', () => {
-      let now = 10;
-      instance._detect(now);
-      const fastFrame = () => instance._detect(now += 20);
-      const slowFrame = () => instance._detect(now += 300);
-      fastFrame();
-      assert.equal(instance.jank, 0);
-      _.times(4, slowFrame);
-      assert.equal(instance.jank, 0);
-      instance._requestAnimationFrame.reset();
-      slowFrame();
-      assert.equal(instance.jank, 1);
-      assert.isFalse(instance._requestAnimationFrame.called);
-      clock.tick(1000);
-      assert.isTrue(instance._requestAnimationFrame.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
index 935de6b..0ba8a22 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -15,9 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-reporting">
-  <script src="gr-jank-detector.js"></script>
   <script src="gr-reporting.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 0947d77..276c137 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -49,14 +49,6 @@
     STARTED_HIDDEN: 'hidden',
   };
 
-  // Frame rate related constants.
-  const JANK = {
-    TYPE: 'lifecycle',
-    CATEGORY: 'UI Latency',
-    // Reported events - alphabetize below.
-    COUNT: 'Jank count',
-  };
-
   // Navigation reporting constants.
   const NAVIGATION = {
     TYPE: 'nav-report',
@@ -78,24 +70,33 @@
     CHANGE_DISPLAYED: 'ChangeDisplayed',
     CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
     DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+    DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
     DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+    DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
     FILE_LIST_DISPLAYED: 'FileListDisplayed',
     PLUGINS_LOADED: 'PluginsLoaded',
     STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
     STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
     STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+    STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
     STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+    STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
     STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
     WEB_COMPONENTS_READY: 'WebComponentsReady',
+    METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
   };
 
   const STARTUP_TIMERS = {};
   STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
+  STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
   // WebComponentsReady timer is triggered from gr-router.
   STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
 
@@ -106,6 +107,9 @@
 
   const pending = [];
 
+  const loadedPlugins = [];
+  const detectedExtensions = [];
+
   const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
       oldOnError(msg, url, line, column, error);
@@ -113,7 +117,13 @@
     if (error) {
       line = line || error.lineNumber;
       column = column || error.columnNumber;
-      msg = msg || error.toString();
+      let shortenedErrorStack = msg;
+      if (error.stack) {
+        const errorStackLines = error.stack.split('\n');
+        shortenedErrorStack = errorStackLines.slice(0,
+            Math.min(3, errorStackLines.length)).join('\n');
+      }
+      msg = shortenedErrorStack || error.toString();
     }
     const payload = {
       url,
@@ -138,13 +148,10 @@
   };
   catchErrors();
 
-  GrJankDetector.start();
-
   // The Polymer pass of JSCompiler requires this to be reassignable
   // eslint-disable-next-line prefer-const
   let GrReporting = Polymer({
     is: 'gr-reporting',
-    _legacyUndefinedCheck: true,
 
     properties: {
       category: String,
@@ -173,9 +180,15 @@
         !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
     },
 
+    _isMetricsPluginLoaded() {
+      return this._arePluginsLoaded() || this._baselines &&
+        !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
+    },
+
     reporter(...args) {
-      const report = (this._arePluginsLoaded() && !pending.length) ?
+      const report = (this._isMetricsPluginLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
+      args.splice(4, 0, loadedPlugins, detectedExtensions);
       report.apply(this, args);
     },
 
@@ -186,23 +199,33 @@
      * @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) {
-        console.error(eventValue.error || eventName);
+        console.error(eventValue && eventValue.error || eventName);
       } else {
-        console.log(eventName + (eventValue !== undefined ?
-          (': ' + eventValue) : ''));
+        if (eventValue !== undefined) {
+          console.log(`Reporting: ${eventName}: ${eventValue}`);
+        } else {
+          console.log(`Reporting: ${eventName}`);
+        }
       }
     },
 
@@ -214,22 +237,27 @@
      * @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.error || eventName);
+        console.error(eventValue && eventValue.error || eventName);
       }
-      if (this._arePluginsLoaded()) {
+      if (this._isMetricsPluginLoaded()) {
         if (pending.length) {
           for (const args of pending.splice(0)) {
             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]);
       }
     },
 
@@ -237,8 +265,7 @@
      * User-perceived app start time, should be reported when the app is ready.
      */
     appStarted(hidden) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-          TIMING.APP_STARTED, this.now());
+      this.timeEnd(TIMING.APP_STARTED);
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
@@ -261,18 +288,15 @@
     },
 
     beforeLocationChanged() {
-      if (GrJankDetector.jank > 0) {
-        this.reporter(
-            JANK.TYPE, JANK.CATEGORY, JANK.COUNT, GrJankDetector.jank);
-        GrJankDetector.jank = 0;
-      }
       for (const prop of Object.keys(this._baselines)) {
         delete this._baselines[prop];
       }
       this.time(TIMER.CHANGE_DISPLAYED);
       this.time(TIMER.CHANGE_LOAD_FULL);
       this.time(TIMER.DASHBOARD_DISPLAYED);
+      this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
       this.time(TIMER.DIFF_VIEW_DISPLAYED);
+      this.time(TIMER.DIFF_VIEW_LOAD_FULL);
       this.time(TIMER.FILE_LIST_DISPLAYED);
     },
 
@@ -313,6 +337,23 @@
       }
     },
 
+    diffViewFullyLoaded() {
+      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+      } else {
+        this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+      }
+    },
+
+    diffViewContentDisplayed() {
+      if (this._baselines.hasOwnProperty(
+          TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
+        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+      } else {
+        this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+      }
+    },
+
     fileListDisplayed() {
       if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
         this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
@@ -323,6 +364,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) {
@@ -336,6 +387,7 @@
      */
     time(name) {
       this._baselines[name] = this.now();
+      window.performance.mark(`${name}-start`);
     },
 
     /**
@@ -344,8 +396,18 @@
     timeEnd(name) {
       if (!this._baselines.hasOwnProperty(name)) { return; }
       const baseTime = this._baselines[name];
-      this._reportTiming(name, this.now() - baseTime);
       delete this._baselines[name];
+      this._reportTiming(name, this.now() - baseTime);
+
+      // Finalize the interval. Either from a registered start mark or
+      // the navigation start time (if baseTime is 0).
+      if (baseTime !== 0) {
+        window.performance.measure(name, `${name}-start`);
+      } else {
+        // Microsft Edge does not handle the 2nd param correctly
+        // (if undefined).
+        window.performance.measure(name);
+      }
     },
 
     /**
@@ -465,7 +527,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 29e70ea..4c561a2 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reporting</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-reporting.html">
 
@@ -93,11 +95,7 @@
     test('beforeLocationChanged', () => {
       element._baselines['garbage'] = 'monster';
       sandbox.stub(element, 'time');
-      GrJankDetector.jank = 42;
       element.beforeLocationChanged();
-      assert.equal(GrJankDetector.jank, 0);
-      assert.isTrue(element.reporter.calledWithExactly(
-          'lifecycle', 'UI Latency', 'Jank count', 42));
       assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
       assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
       assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
@@ -193,7 +191,6 @@
       assert.isTrue(element.reporter.calledOnce);
       assert.throws(() => {
         timer.end();
-        done();
       }, 'Timer for "foo-bar" already ended.');
     });
 
@@ -263,8 +260,8 @@
       test('pluginsLoaded reports time', () => {
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
-        assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42, undefined
+        assert.isTrue(element.defaultReporter.calledWith(
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42
         ));
       });
 
@@ -285,6 +282,24 @@
         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);
+      });
+
       test('reports cached events preserving order', () => {
         element.time('foo');
         element.time('bar');
@@ -334,6 +349,7 @@
 
       test('is reported', () => {
         const error = new Error('bar');
+        error.stack = undefined;
         emulateThrow('bar', 'http://url', 4, 2, error);
         assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
         const payload = reporter.lastCall.args[3];
@@ -345,6 +361,15 @@
         });
       });
 
+      test('is reported with 3 lines of stack', () => {
+        const error = new Error('bar');
+        emulateThrow('bar', 'http://url', 4, 2, error);
+        const expectedStack = error.stack.split('\n').slice(0, 3)
+            .join('\n');
+        assert.isTrue(reporter.calledWith('error', 'exception',
+            expectedStack));
+      });
+
       test('prevent default event handler', () => {
         assert.isTrue(emulateThrow());
       });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 68ddef6..71a5832 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -14,9 +14,10 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -28,6 +29,6 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
   </template>
-  <script src="../../../bower_components/page/page.js"></script>
+  <script src="/bower_components/page/page.js"></script>
   <script src="gr-router.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index a597311..4643616 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -199,7 +199,9 @@
     const reporting = document.createElement('gr-reporting');
 
     window.addEventListener('load', () => {
-      reporting.pageLoaded();
+      setTimeout(() => {
+        reporting.pageLoaded();
+      }, 0);
     });
 
     window.addEventListener('WebComponentsReady', () => {
@@ -209,7 +211,6 @@
 
   Polymer({
     is: 'gr-router',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _app: {
@@ -227,6 +228,7 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.URLEncodingBehavior,
     ],
@@ -237,7 +239,17 @@
     },
 
     _setParams(params) {
-      this._app.params = params;
+      this._appElement().params = params;
+    },
+
+    _appElement() {
+      // In Polymer2 you have to reach through the shadow root of the app
+      // element. This obviously breaks encapsulation.
+      // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
+      // explicitly in app, or by delegating to it.
+      return document.getElementById('app-element') ||
+          document.getElementById('app').shadowRoot.getElementById(
+              'app-element');
     },
 
     _redirect(url) {
@@ -1396,9 +1408,7 @@
       }
     },
 
-    // TODO fix this so it properly redirects
-    // to /settings#Agreements (Scrolls down)
-    _handleAgreementsRoute(data) {
+    _handleAgreementsRoute() {
       this._redirect('/settings/#Agreements');
     },
 
@@ -1497,7 +1507,7 @@
       // Note: the app's 404 display is tightly-coupled with catching 404
       // network responses, so we simulate a 404 response status to display it.
       // TODO: Decouple the gr-app error view from network responses.
-      this._app.dispatchEvent(new CustomEvent('page-error',
+      this._appElement().dispatchEvent(new CustomEvent('page-error',
           {detail: {response: {status: 404}}}));
     },
   });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index 3b65bf6..dd66499 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-router</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-router.html">
 
@@ -662,11 +664,12 @@
       });
 
       test('_handleDefaultRoute on first load', () => {
-        element._app = {dispatchEvent: sinon.stub()};
+        const appElementStub = {dispatchEvent: sinon.stub()};
+        element._appElement = () => appElementStub;
         element._handleDefaultRoute();
-        assert.isTrue(element._app.dispatchEvent.calledOnce);
+        assert.isTrue(appElementStub.dispatchEvent.calledOnce);
         assert.equal(
-            element._app.dispatchEvent.lastCall.args[0].detail.response.status,
+            appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
             404);
       });
 
@@ -682,7 +685,8 @@
         sandbox.stub(window, 'page');
         element._startRouter();
 
-        element._app = {dispatchEvent: sinon.stub()};
+        const appElementStub = {dispatchEvent: sinon.stub()};
+        element._appElement = () => appElementStub;
         element._handleDefaultRoute();
 
         onExit('', () => {}); // we left page;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 3a48213..0cdef8c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -30,11 +30,10 @@
       gr-autocomplete {
         background-color: var(--view-background-color);
         border: 1px solid var(--border-color);
-        border-radius: 2px;
+        border-radius: var(--border-radius);
         flex: 1;
-        font: inherit;
         outline: none;
-        padding: .25em;
+        padding: var(--spacing-xs);
       }
     </style>
     <form>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index c877ac4..0030bab 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -60,7 +60,6 @@
     'is:merged',
     'is:open',
     'is:owner',
-    'is:pending',
     'is:private',
     'is:reviewed',
     'is:reviewer',
@@ -90,7 +89,6 @@
     'status:closed',
     'status:merged',
     'status:open',
-    'status:pending',
     'status:reviewed',
     'topic:',
     'tr:',
@@ -106,7 +104,6 @@
 
   Polymer({
     is: 'gr-search-bar',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a search is committed
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index b162828..a4927c3 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-search-bar</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-search-bar.html">
 <script src="../../../scripts/util.js"></script>
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 4c98068..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
@@ -14,9 +14,9 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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 65141aa..2446486 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
@@ -23,7 +23,6 @@
 
   Polymer({
     is: 'gr-smart-search',
-    _legacyUndefinedCheck: true,
 
     properties: {
       searchQuery: String,
@@ -49,7 +48,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
     ],
 
     attached() {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
index af0fc3c..a70eb7c 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-smart-search</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-smart-search.html">
 
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
similarity index 62%
copy from polygerrit-ui/app/elements/gr-app-it_test.html
copy to polygerrit-ui/app/elements/custom-dark-theme_test.html
index 2601aeb..4cf35f1 100644
--- a/polygerrit-ui/app/elements/gr-app-it_test.html
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</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.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.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-app.html">
+<link rel="import" href="./gr-app.html">
 
 <script>void(0);</script>
 
@@ -33,7 +35,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-app integration tests', () => {
+  suite('gr-app custom dark theme tests', () => {
     let sandbox;
     let element;
 
@@ -61,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);
+            });
       });
     });
 
@@ -75,18 +86,16 @@
       sandbox.restore();
     });
 
-    test('applies --primary-text-color', () => {
+    test('applies the right theme', () => {
       assert.equal(
-          element.getComputedStyleValue('--primary-text-color'), '#F00BAA');
-    });
-
-    test('applies --header-background-color', () => {
-      assert.equal(element.getComputedStyleValue('--header-background-color'),
-          '#F01BAA');
-    });
-    test('applies --footer-background-color', () => {
-      assert.equal(element.getComputedStyleValue('--footer-background-color'),
-          '#F02BAA');
+          util.getComputedStyleValue('--primary-text-color', element),
+          'red');
+      assert.equal(
+          util.getComputedStyleValue('--header-background-color', element),
+          'black');
+      assert.equal(
+          util.getComputedStyleValue('--footer-background-color', element),
+          '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 64%
rename from polygerrit-ui/app/elements/gr-app-it_test.html
rename to polygerrit-ui/app/elements/custom-light-theme_test.html
index 2601aeb..e346af5 100644
--- a/polygerrit-ui/app/elements/gr-app-it_test.html
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</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.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.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-app.html">
 
@@ -33,7 +35,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-app integration tests', () => {
+  suite('gr-app custom light theme tests', () => {
     let sandbox;
     let element;
 
@@ -61,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);
+            });
       });
     });
 
@@ -75,17 +86,15 @@
       sandbox.restore();
     });
 
-    test('applies --primary-text-color', () => {
+    test('applies the right theme', () => {
       assert.equal(
-          element.getComputedStyleValue('--primary-text-color'), '#F00BAA');
-    });
-
-    test('applies --header-background-color', () => {
-      assert.equal(element.getComputedStyleValue('--header-background-color'),
+          util.getComputedStyleValue('--primary-text-color', element),
+          '#F00BAA');
+      assert.equal(
+          util.getComputedStyleValue('--header-background-color', element),
           '#F01BAA');
-    });
-    test('applies --footer-background-color', () => {
-      assert.equal(element.getComputedStyleValue('--footer-background-color'),
+      assert.equal(
+          util.getComputedStyleValue('--footer-background-color', element),
           '#F02BAA');
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
index 7bf71f5..b7994e6 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'comment-api-mock',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
index c31bd1166..317e9e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 37491d2..1e8158d 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -19,35 +19,6 @@
 
   const PARENT = 'PARENT';
 
-  const Defs = {};
-
-  /**
-   * @typedef {{
-   *    basePatchNum: (string|number),
-   *    patchNum: (number),
-   * }}
-   */
-  Defs.patchRange;
-
-  /**
-   * @typedef {{
-   *    changeNum: number,
-   *    path: string,
-   *    patchRange: !Defs.patchRange,
-   *    projectConfig: (Object|undefined),
-   * }}
-   */
-  Defs.commentMeta;
-
-  /**
-   * @typedef {{
-   *    meta: !Defs.commentMeta,
-   *    left: !Array,
-   *    right: !Array,
-   * }}
-   */
-  Defs.commentsBySide;
-
   /**
    * Construct a change comments object, which can be data-bound to child
    * elements of that which uses the gr-comment-api.
@@ -92,7 +63,7 @@
    * Paths with comments are mapped to true, whereas paths without comments
    * are not mapped.
    *
-   * @param {Defs.patchRange=} opt_patchRange The patch-range object containing
+   * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing
    *     patchNum and basePatchNum properties to represent the range.
    * @return {!Object}
    */
@@ -251,17 +222,26 @@
    * arrays of comments in on either side of the patch range for that path.
    *
    * @param {!string} path
-   * @param {!Defs.patchRange} patchRange The patch-range object containing patchNum
+   * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
    *     and basePatchNum properties to represent the range.
    * @param {Object=} opt_projectConfig Optional project config object to
    *     include in the meta sub-object.
-   * @return {!Defs.commentsBySide}
+   * @return {!Gerrit.CommentsBySide}
    */
   ChangeComments.prototype.getCommentsBySideForPath = function(path,
       patchRange, opt_projectConfig) {
-    const comments = this.comments[path] || [];
-    const drafts = this.drafts[path] || [];
-    const robotComments = this.robotComments[path] || [];
+    let comments = [];
+    let drafts = [];
+    let robotComments = [];
+    if (this.comments && this.comments[path]) {
+      comments = this.comments[path];
+    }
+    if (this.drafts && this.drafts[path]) {
+      drafts = this.drafts[path];
+    }
+    if (this.robotComments && this.robotComments[path]) {
+      robotComments = this.robotComments[path];
+    }
 
     drafts.forEach(d => { d.__draft = true; });
 
@@ -430,7 +410,7 @@
    * given patch range.
    *
    * @param {!Object} comment
-   * @param {!Defs.patchRange} range
+   * @param {!Gerrit.PatchRange} range
    * @return {boolean}
    */
   ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
@@ -462,7 +442,7 @@
    * given patch range.
    *
    * @param {!Object} comment
-   * @param {!Defs.patchRange} range
+   * @param {!Gerrit.PatchRange} range
    * @return {boolean}
    */
   ChangeComments.prototype._isInRevisionOfPatchRange = function(comment,
@@ -475,7 +455,7 @@
    * Whether the given comment should be included in the given patch range.
    *
    * @param {!Object} comment
-   * @param {!Defs.patchRange} range
+   * @param {!Gerrit.PatchRange} range
    * @return {boolean|undefined}
    */
   ChangeComments.prototype._isInPatchRange = function(comment, range) {
@@ -485,7 +465,6 @@
 
   Polymer({
     is: 'gr-comment-api',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 1ca2a69..47181f9 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-comment-api.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
index 56a6fb9..549bf43 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <dom-module id="gr-coverage-layer">
   <template>
   </template>
+  <script src="../../../types/types.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-coverage-layer.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
index e8d6900..3d9c172 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -17,27 +17,6 @@
 (function() {
   'use strict';
 
-  /** @enum {string} */
-  Gerrit.CoverageType = {
-    /**
-     * start_character and end_character of the range will be ignored for this
-     * type.
-     */
-    COVERED: 'COVERED',
-    /**
-     * start_character and end_character of the range will be ignored for this
-     * type.
-     */
-    NOT_COVERED: 'NOT_COVERED',
-    PARTIALLY_COVERED: 'PARTIALLY_COVERED',
-    /**
-     * You don't have to use this. If there is no coverage information for a
-     * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-     * end_character of the range will be ignored for this type.
-     */
-    NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
-  };
-
   const TOOLTIP_MAP = new Map([
     [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
     [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
@@ -45,15 +24,6 @@
     [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
   ]);
 
-  /**
-   * @typedef {{
-   *   side: string,
-   *   type: Gerrit.CoverageType,
-   *   code_range: Gerrit.Range,
-   * }}
-   */
-  Gerrit.CoverageRange;
-
   Polymer({
     is: 'gr-coverage-layer',
 
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
index edd88a2..45a67e1 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-coverage-layer</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 11bea8c..283b7fd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -95,7 +95,7 @@
         image._width = imageEl.naturalWidth;
         this._updateImageLabel(section, className, image);
       }.bind(this);
-      imageEl.src = 'data:' + image.type + ';base64, ' + image.body;
+      imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
       imageEl.addEventListener('error', () => {
         imageEl.remove();
         td.textContent = '[Image failed to load]';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 1ef278f..bb590ba 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -35,6 +35,9 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
     const pairs = group.getSideBySidePairs();
     for (let i = 0; i < pairs.length; i++) {
       sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 6be2097..144cc56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -35,9 +35,18 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
 
     for (let i = 0; i < group.lines.length; ++i) {
-      sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
+      const line = group.lines[i];
+      // If only whitespace has changed and the settings ask for whitespace to
+      // be ignored, only render the right-side line in unified diff mode.
+      if (group.ignoredWhitespaceOnly && line.type == GrDiffLine.Type.REMOVE) {
+        continue;
+      }
+      sectionEl.appendChild(this._createRow(sectionEl, line));
     }
     return sectionEl;
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
new file mode 100644
index 0000000..19e017d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
@@ -0,0 +1,205 @@
+<!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>GrDiffBuilderUnified</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-diff/gr-diff-line.js"></script>
+<script src="../gr-diff/gr-diff-group.js"></script>
+<script src="../gr-diff-highlight/gr-annotation.js"></script>
+<script src="gr-diff-builder.js"></script>
+<script src="gr-diff-builder-unified.js"></script>
+
+<script>void(0);</script>
+
+<script>
+  suite('GrDiffBuilderUnified tests', () => {
+    let prefs;
+    let outputEl;
+    let diffBuilder;
+
+    setup(()=> {
+      prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      outputEl = document.createElement('div');
+      diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+    });
+
+    suite('buildSectionElement for BOTH group', () => {
+      let lines;
+      let group;
+
+      setup(() => {
+        lines = [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
+        ];
+        lines[0].text = 'def hello_world():';
+        lines[1].text = '  print "Hello World";';
+        lines[2].text = '  return True';
+
+        group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
+      });
+
+      test('creates the section', () => {
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        assert.isTrue(sectionEl.classList.contains('section'));
+        assert.isTrue(sectionEl.classList.contains('both'));
+      });
+
+      test('creates each unchanged row once', () => {
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+        assert.equal(rowEls.length, 3);
+
+        assert.equal(
+            rowEls[0].querySelector('.lineNum.left').textContent,
+            lines[0].beforeNumber);
+        assert.equal(
+            rowEls[0].querySelector('.lineNum.right').textContent,
+            lines[0].afterNumber);
+        assert.equal(
+            rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+        assert.equal(
+            rowEls[1].querySelector('.lineNum.left').textContent,
+            lines[1].beforeNumber);
+        assert.equal(
+            rowEls[1].querySelector('.lineNum.right').textContent,
+            lines[1].afterNumber);
+        assert.equal(
+            rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+        assert.equal(
+            rowEls[2].querySelector('.lineNum.left').textContent,
+            lines[2].beforeNumber);
+        assert.equal(
+            rowEls[2].querySelector('.lineNum.right').textContent,
+            lines[2].afterNumber);
+        assert.equal(
+            rowEls[2].querySelector('.content').textContent, lines[2].text);
+      });
+    });
+
+    suite('buildSectionElement for DELTA group', () => {
+      let lines;
+      let group;
+
+      setup(() => {
+        lines = [
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
+          new GrDiffLine(GrDiffLine.Type.ADD, 2),
+          new GrDiffLine(GrDiffLine.Type.ADD, 3),
+        ];
+        lines[0].text = 'def hello_world():';
+        lines[1].text = '  print "Hello World"';
+        lines[2].text = 'def hello_universe()';
+        lines[3].text = '  print "Hello Universe"';
+
+        group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+      });
+
+      test('creates the section', () => {
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        assert.isTrue(sectionEl.classList.contains('section'));
+        assert.isTrue(sectionEl.classList.contains('delta'));
+      });
+
+      test('creates the section with class if ignoredWhitespaceOnly', () => {
+        group.ignoredWhitespaceOnly = true;
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+      });
+
+      test('creates the section with class if dueToRebase', () => {
+        group.dueToRebase = true;
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+      });
+
+      test('creates first the removed and then the added rows', () => {
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+        assert.equal(rowEls.length, 4);
+
+        assert.equal(
+            rowEls[0].querySelector('.lineNum.left').textContent,
+            lines[0].beforeNumber);
+        assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+        assert.equal(
+            rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+        assert.equal(
+            rowEls[1].querySelector('.lineNum.left').textContent,
+            lines[1].beforeNumber);
+        assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+        assert.equal(
+            rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+        assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+        assert.equal(
+            rowEls[2].querySelector('.lineNum.right').textContent,
+            lines[2].afterNumber);
+        assert.equal(
+            rowEls[2].querySelector('.content').textContent, lines[2].text);
+
+        assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+        assert.equal(
+            rowEls[3].querySelector('.lineNum.right').textContent,
+            lines[3].afterNumber);
+        assert.equal(
+            rowEls[3].querySelector('.content').textContent, lines[3].text);
+      });
+
+      test('creates only the added rows if only ignored whitespace', () => {
+        group.ignoredWhitespaceOnly = true;
+        const sectionEl = diffBuilder.buildSectionElement(group);
+        const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+        assert.equal(rowEls.length, 2);
+
+        assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+        assert.equal(
+            rowEls[0].querySelector('.lineNum.right').textContent,
+            lines[2].afterNumber);
+        assert.equal(
+            rowEls[0].querySelector('.content').textContent, lines[2].text);
+
+        assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+        assert.equal(
+            rowEls[1].querySelector('.lineNum.right').textContent,
+            lines[3].afterNumber);
+        assert.equal(
+            rowEls[1].querySelector('.content').textContent, lines[3].text);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index d42d8c1..40fbe3c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,12 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../gr-coverage-layer/gr-coverage-layer.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
-<link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
 
 <dom-module id="gr-diff-builder">
   <template>
@@ -29,9 +28,6 @@
     <gr-ranged-comment-layer
         id="rangeLayer"
         comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
-    <gr-syntax-layer
-        id="syntaxLayer"
-        diff="[[diff]]"></gr-syntax-layer>
     <gr-coverage-layer
         id="coverageLayerLeft"
         coverage-ranges="[[_leftCoverageRanges]]"
@@ -43,7 +39,6 @@
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
   </template>
   <script src="../../../scripts/util.js"></script>
   <script src="../gr-diff/gr-diff-line.js"></script>
@@ -63,18 +58,10 @@
         UNIFIED: 'UNIFIED_DIFF',
       };
 
-      // If any line of the diff is more than the character limit, then disable
-      // syntax highlighting for the entire file.
-      const SYNTAX_MAX_LINE_LENGTH = 500;
-
-      // Disable syntax highlighting if the overall diff is too large.
-      const SYNTAX_MAX_DIFF_LENGTH = 20000;
-
       const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
       Polymer({
         is: 'gr-diff-builder',
-        _legacyUndefinedCheck: true,
 
         /**
          * Fired when the diff begins rendering.
@@ -83,21 +70,13 @@
          */
 
         /**
-         * Fired when the diff finishes rendering text content and starts
-         * syntax highlighting.
+         * Fired when the diff finishes rendering text content.
          *
          * @event render-content
          */
 
-        /**
-         * Fired when the diff finishes syntax highlighting.
-         *
-         * @event render-syntax
-         */
-
         properties: {
           diff: Object,
-          diffPath: String,
           changeNum: String,
           patchNum: String,
           viewMode: String,
@@ -138,8 +117,16 @@
            * @type {?Object}
            */
           _cancelableRenderPromise: Object,
+          layers: {
+            type: Array,
+            value: [],
+          },
         },
 
+        behaviors: [
+          Gerrit.FireBehavior,
+        ],
+
         get diffElement() {
           return this.queryEffectiveChildren('#diffTable');
         },
@@ -163,11 +150,10 @@
           // attached before plugins are installed.
           this._setupAnnotationLayers();
 
-          this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
           this._showTabs = !!prefs.show_tabs;
           this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
 
-          // Stop the processor and syntax layer (if they're running).
+          // Stop the processor if it's running.
           this.cancel();
 
           this._builder = this._getDiffBuilder(this.diff, prefs);
@@ -180,7 +166,8 @@
 
           const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-          this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
+          this.dispatchEvent(new CustomEvent(
+              'render-start', {bubbles: true, composed: true}));
           this._cancelableRenderPromise = util.makeCancelable(
               this.$.processor.process(this.diff.content, isBinary)
                   .then(() => {
@@ -188,17 +175,7 @@
                       this._builder.renderDiff();
                     }
                     this.dispatchEvent(new CustomEvent('render-content',
-                        {bubbles: true}));
-
-                    if (this._diffTooLargeForSyntax()) {
-                      this.$.syntaxLayer.enabled = false;
-                    }
-
-                    return this.$.syntaxLayer.process();
-                  })
-                  .then(() => {
-                    this.dispatchEvent(
-                        new CustomEvent('render-syntax', {bubbles: true}));
+                        {bubbles: true, composed: true}));
                   }));
           return this._cancelableRenderPromise
               .finally(() => { this._cancelableRenderPromise = null; })
@@ -211,7 +188,6 @@
         _setupAnnotationLayers() {
           const layers = [
             this._createTrailingWhitespaceLayer(),
-            this.$.syntaxLayer,
             this._createIntralineLayer(),
             this._createTabIndicatorLayer(),
             this.$.rangeLayer,
@@ -219,12 +195,9 @@
             this.$.coverageLayerRight,
           ];
 
-          // Get layers from plugins (if any).
-          for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-              this.diffPath, this.changeNum, this.patchNum)) {
-            layers.push(pluginLayer);
+          if (this.layers) {
+            layers.push(...this.layers);
           }
-
           this._layers = layers;
         },
 
@@ -289,7 +262,7 @@
           const contextIndex = groups.findIndex(group =>
             group.element === sectionEl
           );
-          groups.splice(...[contextIndex, 1].concat(newGroups));
+          groups.splice(contextIndex, 1, ...newGroups);
 
           for (const newGroup of newGroups) {
             this._builder.emitGroup(newGroup, sectionEl);
@@ -301,7 +274,6 @@
 
         cancel() {
           this.$.processor.cancel();
-          this.$.syntaxLayer.cancel();
           if (this._cancelableRenderPromise) {
             this._cancelableRenderPromise.cancel();
             this._cancelableRenderPromise = null;
@@ -314,7 +286,7 @@
           this.dispatchEvent(new CustomEvent('show-alert', {
             detail: {
               message,
-            }, bubbles: true}));
+            }, bubbles: true, composed: true}));
           throw Error(`Invalid preference value: ${pref}`);
         },
 
@@ -440,45 +412,10 @@
           };
         },
 
-        /**
-         * @return {boolean} whether any of the lines in _groups are longer
-         * than SYNTAX_MAX_LINE_LENGTH.
-         */
-        _anyLineTooLong() {
-          return this._groups.reduce((acc, group) => {
-            return acc || group.lines.reduce((acc, line) => {
-              return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
-            }, false);
-          }, false);
-        },
-
-        _diffTooLargeForSyntax() {
-          return this._anyLineTooLong() ||
-              this.getDiffLength() > SYNTAX_MAX_DIFF_LENGTH;
-        },
-
         setBlame(blame) {
           if (!this._builder || !blame) { return; }
           this._builder.setBlame(blame);
         },
-
-        /**
-         * Get the approximate length of the diff as the sum of the maximum
-         * length of the chunks.
-         *
-         * @return {number}
-         */
-        getDiffLength() {
-          return this.diff.content.reduce((sum, sec) => {
-            if (sec.hasOwnProperty('ab')) {
-              return sum + sec.ab.length;
-            } else {
-              return sum + Math.max(
-                  sec.hasOwnProperty('a') ? sec.a.length : 0,
-                  sec.hasOwnProperty('b') ? sec.b.length : 0);
-            }
-          }, 0);
-        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 65a56f0..54303f6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -115,18 +115,6 @@
     group.element = element;
   };
 
-  GrDiffBuilder.prototype.renderSection = function(element) {
-    for (let i = 0; i < this.groups.length; i++) {
-      const group = this.groups[i];
-      if (group.element === element) {
-        const newElement = this.buildSectionElement(group);
-        group.element.parentElement.replaceChild(newElement, group.element);
-        group.element = newElement;
-        break;
-      }
-    }
-  };
-
   GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
     const groups = [];
@@ -233,57 +221,38 @@
         group => { return group.element; });
   };
 
-  // TODO(wyatta): Move this completely into the processor.
-  GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
-      hiddenRange) {
-    const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-    const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-    const linesAfterCtx = lines.slice(hiddenRange[1]);
-
-    if (linesBeforeCtx.length > 0) {
-      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
-    }
-
-    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-    ctxLine.contextGroup =
-        new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
-    groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
-        [ctxLine]));
-
-    if (linesAfterCtx.length > 0) {
-      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
-    }
-  };
-
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
-    if (!line.contextGroup || !line.contextGroup.lines.length) {
-      return null;
-    }
+    if (!line.contextGroups) return null;
+
+    const numLines =
+        line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
+        line.contextGroups[0].lineRange.left.start + 1;
+
+    if (numLines === 0) return null;
 
     const td = this._createElement('td');
-    const showPartialLinks =
-        line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
 
     if (showPartialLinks) {
       td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.ABOVE, section, line));
+          GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
       td.appendChild(document.createTextNode(' - '));
     }
 
     td.appendChild(this._createContextButton(
-        GrDiffBuilder.ContextButtonType.ALL, section, line));
+        GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
 
     if (showPartialLinks) {
       td.appendChild(document.createTextNode(' - '));
       td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.BELOW, section, line));
+          GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
     }
 
     return td;
   };
 
-  GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
-    const contextLines = line.contextGroup.lines;
+  GrDiffBuilder.prototype._createContextButton = function(type, section, line,
+      numLines) {
     const context = PARTIAL_CONTEXT_AMOUNT;
 
     const button = this._createElement('gr-button', 'showContext');
@@ -291,20 +260,20 @@
     button.setAttribute('no-uppercase', true);
 
     let text;
-    const groups = []; // The groups that replace this one if tapped.
+    let groups = []; // The groups that replace this one if tapped.
 
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      text = 'Show ' + contextLines.length + ' common line';
-      if (contextLines.length > 1) { text += 's'; }
-      groups.push(line.contextGroup);
+      text = 'Show ' + numLines + ' common line';
+      if (numLines > 1) { text += 's'; }
+      groups.push(...line.contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       text = '+' + context + '↑';
-      this._insertContextGroups(groups, contextLines,
-          [context, contextLines.length]);
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          context, numLines);
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
       text = '+' + context + '↓';
-      this._insertContextGroups(groups, contextLines,
-          [0, contextLines.length - context]);
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          0, numLines - context);
     }
 
     Polymer.dom(button).textContent = text;
@@ -337,8 +306,6 @@
       return td;
     } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
       td.classList.add('contextLineNum');
-      td.setAttribute('data-value', '@@');
-      td.textContent = '@@';
     } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
       td.classList.add('lineNum');
       td.setAttribute('data-value', number);
@@ -353,6 +320,12 @@
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
+
+    // If intraline info is not available, the entire line will be
+    // considered as changed and marked as dark red / green color
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
     td.classList.add(line.type);
 
     const lineLimit =
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index a8db47a..42414b7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff/gr-diff-line.js"></script>
@@ -28,13 +30,14 @@
 <script src="../gr-diff-highlight/gr-annotation.js"></script>
 <script src="gr-diff-builder.js"></script>
 
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff-builder.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
-  <template>
+  <template is="dom-template">
     <gr-diff-builder>
       <table id="diffTable"></table>
     </gr-diff-builder>
@@ -88,26 +91,37 @@
     });
 
     test('context control buttons', () => {
-      const section = {};
-      const line = {contextGroup: {lines: []}};
-
       // Create 10 lines.
+      const lines = [];
       for (let i = 0; i < 10; i++) {
-        line.contextGroup.lines.push('lorem upsum');
+        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.beforeNumber = i + 1;
+        line.afterNumber = i + 1;
+        line.text = 'lorem upsum';
+        lines.push(line);
       }
 
+      const contextLine = {
+        contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
+      };
+
+      const section = {};
       // Does not include +10 buttons when there are fewer than 11 lines.
-      let td = builder._createContextControl(section, line);
+      let td = builder._createContextControl(section, contextLine);
       let buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 1);
       assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
 
       // Add another line.
-      line.contextGroup.lines.push('lorem upsum');
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'lorem upsum';
+      line.beforeNumber = 11;
+      line.afterNumber = 11;
+      contextLine.contextGroups[0].addLine(line);
 
       // Includes +10 buttons when there are at least 11 lines.
-      td = builder._createContextControl(section, line);
+      td = builder._createContextControl(section, contextLine);
       buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 3);
@@ -577,31 +591,39 @@
       });
     });
 
-    suite('layers from plugins', () => {
+    suite('layers', () => {
       let element;
       let initialLayersCount;
-
+      let withLayerCount;
       setup(() => {
+        const layers = [];
         element = fixture('basic');
+        element.layers = layers;
         element._showTrailingWhitespace = true;
         element._setupAnnotationLayers();
         initialLayersCount = element._layers.length;
       });
 
-      test('no plugin layers', () => {
-        const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
-            .returns([]);
+      test('no layers', () => {
         element._setupAnnotationLayers();
-        assert.isTrue(getDiffLayersStub.called);
         assert.equal(element._layers.length, initialLayersCount);
       });
 
-      test('with plugin layers', () => {
-        const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
-            .returns([{}, {}]);
-        element._setupAnnotationLayers();
-        assert.isTrue(getDiffLayersStub.called);
-        assert.equal(element._layers.length, initialLayersCount + 2);
+      suite('with layers', () => {
+        const layers = [{}, {}];
+        setup(() => {
+          element = fixture('basic');
+          element.layers = layers;
+          element._showTrailingWhitespace = true;
+          element._setupAnnotationLayers();
+          withLayerCount = element._layers.length;
+        });
+        test('with layers', () => {
+          element._setupAnnotationLayers();
+          assert.equal(element._layers.length, withLayerCount);
+          assert.equal(initialLayersCount + layers.length,
+              withLayerCount);
+        });
       });
     });
 
@@ -712,7 +734,6 @@
         element.viewMode = 'SIDE_BY_SIDE';
         processStub = sandbox.stub(element.$.processor, 'process')
             .returns(Promise.resolve());
-        sandbox.stub(element, '_anyLineTooLong').returns(true);
         keyLocations = {left: {}, right: {}};
         prefs = {
           line_length: 10,
@@ -802,15 +823,6 @@
         element.render(keyLocations, prefs).then(done);
       });
 
-      test('renderSection', () => {
-        let section = outputEl.querySelector('stub:nth-of-type(2)');
-        const prevInnerHTML = section.innerHTML;
-        section.innerHTML = 'wiped';
-        element._builder.renderSection(section);
-        section = outputEl.querySelector('stub:nth-of-type(2)');
-        assert.equal(section.innerHTML, prevInnerHTML);
-      });
-
       test('addColumns is called', done => {
         element.render(keyLocations, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
@@ -841,37 +853,14 @@
               .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
           assert.include(firedEventTypes, 'render-content');
-          assert.include(firedEventTypes, 'render-syntax');
-          done();
-        });
-      });
-
-      test('rendering normal-sized diff does not disable syntax', () => {
-        assert.isTrue(element.$.syntaxLayer.enabled);
-      });
-
-      test('rendering large diff disables syntax', done => {
-        // Before it renders, set the first diff line to 500 '*' characters.
-        element.diff.content[0].a = [new Array(501).join('*')];
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        element.render(keyLocations, prefs).then(() => {
-          assert.isFalse(element.$.syntaxLayer.enabled);
           done();
         });
       });
 
       test('cancel', () => {
         const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
-        const syntaxCancelStub = sandbox.stub(element.$.syntaxLayer, 'cancel');
         element.cancel();
         assert.isTrue(processorCancelStub.called);
-        assert.isTrue(syntaxCancelStub.called);
       });
     });
 
@@ -900,10 +889,6 @@
         });
       });
 
-      test('getDiffLength', () => {
-        assert.equal(element.getDiffLength(diff), 52);
-      });
-
       test('getContentByLine', () => {
         let actual;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index c24574e..99d0498 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 
 <dom-module id="gr-diff-cursor">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 1cfd5e7..6ddb390 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -37,7 +37,6 @@
 
   Polymer({
     is: 'gr-diff-cursor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -65,7 +64,10 @@
       /**
        * If set, the cursor will attempt to move to the line number (instead of
        * the first chunk) the next time the diff renders. It is set back to null
-       * when used.
+       * when used. It should be only used if you want the line to be focused
+       * after initialization of the component and page should scroll
+       * to that position. This parameter should be set at most for one gr-diff
+       * element in the page.
        *
        * @type {?number}
        */
@@ -136,11 +138,11 @@
       }
     },
 
-    moveToNextChunk() {
+    moveToNextChunk(opt_clipToTop) {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
           target => {
             return target.parentNode.scrollHeight;
-          });
+          }, opt_clipToTop);
       this._fixSide();
     },
 
@@ -192,16 +194,20 @@
     },
 
     getTargetDiffElement() {
-      // Find the parent diff element of the cursor row.
-      for (let diff = this.diffRow; diff; diff = diff.parentElement) {
-        if (diff.tagName === 'GR-DIFF') { return diff; }
+      if (!this.diffRow) return null;
+
+      const hostOwner = Polymer.dom(/** @type {Node} */ (this.diffRow))
+          .getOwnerRoot();
+      if (hostOwner && hostOwner.host &&
+          hostOwner.host.tagName === 'GR-DIFF') {
+        return hostOwner.host;
       }
       return null;
     },
 
     moveToFirstChunk() {
       this.$.cursorManager.moveToStart();
-      this.moveToNextChunk();
+      this.moveToNextChunk(true);
     },
 
     reInitCursor() {
@@ -224,8 +230,12 @@
 
     handleDiffUpdate() {
       this._updateStops();
-
       if (!this.diffRow) {
+        // does not scroll during init unless requested
+        const scrollingBehaviorForInit = this.initialLineNumber ?
+          ScrollBehavior.KEEP_VISIBLE :
+          ScrollBehavior.NEVER;
+        this._scrollBehavior = scrollingBehaviorForInit;
         this.reInitCursor();
       }
       this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
@@ -296,7 +306,7 @@
     },
 
     _rowHasThread(row) {
-      return row.querySelector('.comment-thread');
+      return row.querySelector('.thread-group');
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index f111378..1c1100d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -18,14 +18,17 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-cursor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff/gr-diff.html">
 <link rel="import" href="./gr-diff-cursor.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 
 <script>void(0);</script>
@@ -207,40 +210,55 @@
       assert.equal(cursorElement.side, 'left');
     });
 
-    test('initialLineNumber disabled', done => {
+    test('initialLineNumber not provided', done => {
+      let scrollBehaviorDuringMove;
       const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
-      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
+      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
+          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
 
       function renderHandler() {
         diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToNumStub.called);
         assert.isTrue(moveToChunkStub.called);
+        assert.equal(scrollBehaviorDuringMove, 'never');
+        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
         done();
       }
       diffElement.addEventListener('render', renderHandler);
       diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
-    test('initialLineNumber enabled', done => {
-      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+    test('initialLineNumber provided', done => {
+      let scrollBehaviorDuringMove;
+      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
+          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
       const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
-
       function renderHandler() {
         diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToChunkStub.called);
         assert.isTrue(moveToNumStub.called);
         assert.equal(moveToNumStub.lastCall.args[0], 10);
         assert.equal(moveToNumStub.lastCall.args[1], 'right');
+        assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
         done();
       }
       diffElement.addEventListener('render', renderHandler);
-
       cursorElement.initialLineNumber = 10;
       cursorElement.side = 'right';
 
       diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
+    test('getTargetDiffElement', () => {
+      cursorElement.initialLineNumber = 1;
+      assert.isTrue(!!cursorElement.diffRow);
+      assert.equal(
+          cursorElement.getTargetDiffElement(),
+          diffElement
+      );
+    });
+
     test('getAddress', () => {
       // It should initialize to the first chunk: line 5 of the revision.
       assert.deepEqual(cursorElement.getAddress(),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index c07d370..c1bf3ed 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-annotation.js"></script>
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index c912a16..3b17190 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -14,10 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
 
 <dom-module id="gr-diff-highlight">
   <template>
@@ -25,14 +26,6 @@
       :host {
         position: relative;
       }
-      .contentWrapper ::content .range {
-        background-color: var(--diff-highlight-range-color);
-        display: inline;
-      }
-      .contentWrapper ::content .rangeHighlight {
-        background-color: var(--diff-highlight-range-hover-color);
-        display: inline;
-      }
       gr-selection-action-box {
         /**
          * Needs z-index to apear above wrapped content, since it's inseted
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index c820668..0c6f4a3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-diff-highlight',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {!Array<!Gerrit.HoveredRange>} */
@@ -36,6 +35,10 @@
       _cachedDiffBuilder: Object,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     listeners: {
       'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
       'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 2e50fdb..c929e1e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-highlight</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff-highlight.html">
 
@@ -92,7 +94,7 @@
 
         <tbody class="section contextControl">
           <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum" data-value="@@"></td>
+            <td class="left contextLineNum"></td>
             <td>
               <gr-button>+10↑</gr-button>
               -
@@ -100,7 +102,7 @@
               -
               <gr-button>+10↓</gr-button>
             </td>
-            <td class="right contextLineNum" data-value="@@"></td>
+            <td class="right contextLineNum"></td>
             <td>
               <gr-button>+10↑</gr-button>
               -
@@ -179,8 +181,8 @@
         element.commentRanges = [{side: 'right'}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isFalse(element.set.called);
       });
 
@@ -204,8 +206,8 @@
         }}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isTrue(element.set.called);
         const args = element.set.lastCall.args;
         assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
@@ -221,8 +223,8 @@
         element.commentRanges = [{side: 'right'}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseleave', {bubbles: true, composed: true}));
         assert.isFalse(element.set.called);
       });
 
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 927c759..cb482b2 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);
@@ -102,7 +102,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-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
index 53ce6e6..7d60f13 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -15,13 +15,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
 
 <dom-module id="gr-diff-host">
   <template>
@@ -48,7 +50,13 @@
         revision-image=[[_revisionImage]]
         coverage-ranges="[[_coverageRanges]]"
         blame="[[_blame]]"
-        diff="[[diff]]"></gr-diff>
+        layers="[[_layers]]"
+        diff="[[diff]]">
+    </gr-diff>
+    <gr-syntax-layer
+        id="syntaxLayer"
+        enabled="[[_syntaxHighlightingEnabled]]"
+        diff="[[diff]]"></gr-syntax-layer>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting" category="diff"></gr-reporting>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 9760d50..2dd19a0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -35,6 +35,16 @@
     SYNTAX: 'Diff Syntax Render',
   };
 
+  // Disable syntax highlighting if the overall diff is too large.
+  const SYNTAX_MAX_DIFF_LENGTH = 20000;
+
+  // If any line of the diff is more than the character limit, then disable
+  // syntax highlighting for the entire file.
+  const SYNTAX_MAX_LINE_LENGTH = 500;
+
+  // 120 lines is good enough threshold for full-sized window viewport
+  const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+
   const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
 
   /**
@@ -66,7 +76,6 @@
    */
   Polymer({
     is: 'gr-diff-host',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user selects a line.
@@ -111,7 +120,9 @@
       commitRange: Object,
       filesWeblinks: {
         type: Object,
-        value() { return {}; },
+        value() {
+          return {};
+        },
         notify: true,
       },
       hidden: {
@@ -194,9 +205,7 @@
       },
 
       /**
-       * TODO(brohlfs): Replace Object type by Gerrit.CoverageRange.
-       *
-       * @type {!Array<!Object>}
+       * @type {!Array<!Gerrit.CoverageRange>}
        */
       _coverageRanges: {
         type: Array,
@@ -209,9 +218,21 @@
         type: Number,
         computed: '_computeParentIndex(patchRange.*)',
       },
+
+      _syntaxHighlightingEnabled: {
+        type: Boolean,
+        computed:
+          '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+      },
+
+      _layers: {
+        type: Array,
+        value: [],
+      },
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
     ],
 
@@ -229,7 +250,6 @@
 
       'render-start': '_handleRenderStart',
       'render-content': '_handleRenderContent',
-      'render-syntax': '_handleRenderSyntax',
 
       'normalize-range': '_handleNormalizeRange',
     },
@@ -237,6 +257,7 @@
     observers: [
       '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
           ' noRenderOnPrefsChange)',
+      '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
     ],
 
     ready() {
@@ -251,34 +272,35 @@
       });
     },
 
-    /** @return {!Promise} */
-    reload() {
+    /**
+     * @param {boolean=} haveParamsChanged ends reporting events that started
+     * on location change.
+     * @return {!Promise}
+     **/
+    reload(haveParamsChanged) {
       this._loading = true;
       this._errorMessage = null;
       const whitespaceLevel = this._getIgnoreWhitespace();
 
-      this._coverageRanges = [];
-      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
-      this.$.jsAPI.getCoverageRanges(changeNum, path, basePatchNum, patchNum).
-          then(coverageRanges => {
-            if (changeNum !== this.changeNum ||
-                path !== this.path ||
-                basePatchNum !== this.patchRange.basePatchNum ||
-                patchNum !== this.patchRange.patchNum) {
-              return;
-            }
-            this._coverageRanges = coverageRanges;
-          }).catch(err => {
-            console.warn('Loading coverage ranges failed: ', err);
-          });
+      const layers = [this.$.syntaxLayer];
+      // Get layers from plugins (if any).
+      for (const pluginLayer of this.$.jsAPI.getDiffLayers(
+          this.path, this.changeNum, this.patchNum)) {
+        layers.push(pluginLayer);
+      }
+      this._layers = layers;
 
+      if (haveParamsChanged) {
+        // We listen on render viewport only on DiffPage (on paramsChanged)
+        this._listenToViewportRender();
+      }
+
+      this._coverageRanges = [];
+      this._getCoverageData();
       const diffRequest = this._getDiff()
           .then(diff => {
             this._loadedWhitespaceLevel = whitespaceLevel;
             this._reportDiff(diff);
-            if (this._getIgnoreWhitespace() !== WHITESPACE_IGNORE_NONE) {
-              return this._translateChunksToIgnore(diff);
-            }
             return diff;
           })
           .catch(e => {
@@ -293,6 +315,8 @@
         return this._loadDiffAssets(diff);
       });
 
+      // Not waiting for coverage ranges intentionally as
+      // plugin loading should not block the content rendering
       return Promise.all([diffRequest, assetRequest])
           .then(results => {
             const diff = results[0];
@@ -301,8 +325,20 @@
             }
             this.filesWeblinks = this._getFilesWeblinks(diff);
             return new Promise(resolve => {
-              const callback = () => {
-                resolve();
+              const callback = event => {
+                const needsSyntaxHighlighting = event.detail
+                      && event.detail.contentRendered;
+                if (needsSyntaxHighlighting) {
+                  this.$.reporting.time(TimingLabel.SYNTAX);
+                  this.$.syntaxLayer.process().then(() => {
+                    this.$.reporting.timeEnd(TimingLabel.SYNTAX);
+                    this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                    resolve();
+                  });
+                } else {
+                  this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                  resolve();
+                }
                 this.removeEventListener('render', callback);
               };
               this.addEventListener('render', callback);
@@ -315,8 +351,53 @@
           .then(() => { this._loading = false; });
     },
 
+    _getCoverageData() {
+      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
+      this.$.jsAPI.getCoverageAnnotationApi().
+          then(coverageAnnotationApi => {
+            if (!coverageAnnotationApi) return;
+            const provider = coverageAnnotationApi.getCoverageProvider();
+            return provider(changeNum, path, basePatchNum, patchNum)
+                .then(coverageRanges => {
+                  if (!coverageRanges ||
+                    changeNum !== this.changeNum ||
+                    path !== this.path ||
+                    basePatchNum !== this.patchRange.basePatchNum ||
+                    patchNum !== this.patchRange.patchNum) {
+                    return;
+                  }
+
+                  const existingCoverageRanges = this._coverageRanges;
+                  this._coverageRanges = coverageRanges;
+
+                  // Notify with existing coverage ranges
+                  // in case there is some existing coverage data that needs to be removed
+                  existingCoverageRanges.forEach(range => {
+                    coverageAnnotationApi.notify(
+                        path,
+                        range.code_range.start_line,
+                        range.code_range.end_line,
+                        range.side);
+                  });
+
+                  // Notify with new coverage data
+                  coverageRanges.forEach(range => {
+                    coverageAnnotationApi.notify(
+                        path,
+                        range.code_range.start_line,
+                        range.code_range.end_line,
+                        range.side);
+                  });
+                });
+          }).catch(err => {
+            console.warn('Loading coverage ranges failed: ', err);
+          });
+    },
+
     _getFilesWeblinks(diff) {
-      if (!this.commitRange) { return {}; }
+      if (!this.commitRange) {
+        return {};
+      }
       return {
         meta_a: Gerrit.Nav.getFileWebLinks(
             this.projectName, this.commitRange.baseCommit, this.path,
@@ -375,7 +456,6 @@
      * @return {!Array<!HTMLElement>}
      */
     getThreadEls() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this.$.diff).querySelectorAll('.comment-thread'));
     },
@@ -444,7 +524,9 @@
      * Report info about the diff response.
      */
     _reportDiff(diff) {
-      if (!diff || !diff.content) { return; }
+      if (!diff || !diff.content) {
+        return;
+      }
 
       // Count the delta lines stemming from normal deltas, and from
       // due_to_rebase deltas.
@@ -672,7 +754,7 @@
      * @param {!Gerrit.Range=} range
      * @return {?Node}
      */
-    _getThreadEl(lineNum, commentSide, range=undefined) {
+    _getThreadEl(lineNum, commentSide, range = undefined) {
       let line;
       if (commentSide === GrDiffBuilder.Side.LEFT) {
         line = {beforeNumber: lineNum};
@@ -737,50 +819,6 @@
         matchers.some(matcher => matcher(threadEl)));
     },
 
-    /**
-     * Take a diff that was loaded with a ignore-whitespace other than
-     * IGNORE_NONE, and convert delta chunks labeled as common into shared
-     * chunks.
-     *
-     * @param {!Object} diff
-     * @returns {!Object}
-     */
-    _translateChunksToIgnore(diff) {
-      const newDiff = Object.assign({}, diff);
-      const mergedContent = [];
-
-      // Was the last chunk visited a shared chunk?
-      let lastWasShared = false;
-
-      for (const chunk of diff.content) {
-        if (lastWasShared && chunk.common && chunk.b) {
-          // The last chunk was shared and this chunk should be ignored, so
-          // add its revision content to the previous chunk.
-          mergedContent[mergedContent.length - 1].ab.push(...chunk.b);
-        } else if (chunk.common && !chunk.b) {
-          // If the chunk should be ignored, but it doesn't have revision
-          // content, then drop it and continue without updating lastWasShared.
-          continue;
-        } else if (lastWasShared && chunk.ab) {
-          // Both the last chunk and the current chunk are shared. Merge this
-          // chunk's shared content into the previous shared content.
-          mergedContent[mergedContent.length - 1].ab.push(...chunk.ab);
-        } else if (!lastWasShared && chunk.common && chunk.b) {
-          // If the previous chunk was not shared, but this one should be
-          // ignored, then add it as a shared chunk.
-          mergedContent.push({ab: chunk.b});
-        } else {
-          // Otherwise add the chunk as is.
-          mergedContent.push(chunk);
-        }
-
-        lastWasShared = !!mergedContent[mergedContent.length - 1].ab;
-      }
-
-      newDiff.content = mergedContent;
-      return newDiff;
-    },
-
     _getIgnoreWhitespace() {
       if (!this.prefs || !this.prefs.ignore_whitespace) {
         return WHITESPACE_IGNORE_NONE;
@@ -788,14 +826,42 @@
       return this.prefs.ignore_whitespace;
     },
 
-    _whitespaceChanged(preferredWhitespaceLevel, loadedWhitespaceLevel,
+    _whitespaceChanged(
+        preferredWhitespaceLevel, loadedWhitespaceLevel,
         noRenderOnPrefsChange) {
+      // Polymer 2: check for undefined
+      if ([
+        preferredWhitespaceLevel,
+        loadedWhitespaceLevel,
+        noRenderOnPrefsChange,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
           !noRenderOnPrefsChange) {
         this.reload();
       }
     },
 
+    _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
+      // Polymer 2: check for undefined
+      if ([
+        noRenderOnPrefsChange,
+        prefsChangeRecord,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
+      if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
+        return;
+      }
+
+      if (!noRenderOnPrefsChange) {
+        this.reload();
+      }
+    },
+
     /**
      * @param {Object} patchRangeRecord
      * @return {number|null}
@@ -841,8 +907,8 @@
     },
 
     _handleCommentSaveOrDiscard() {
-      this.dispatchEvent(new CustomEvent('diff-comments-modified',
-          {bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'diff-comments-modified', {bubbles: true, composed: true}));
     },
 
     _removeComment(comment) {
@@ -877,6 +943,42 @@
           item => item.__draftID === comment.__draftID);
     },
 
+    _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
+      if (!preferenceChangeRecord ||
+          !preferenceChangeRecord.base ||
+          !preferenceChangeRecord.base.syntax_highlighting ||
+          !diff) {
+        return false;
+      }
+      return !this._anyLineTooLong(diff) &&
+          this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
+    },
+
+    /**
+     * @return {boolean} whether any of the lines in diff are longer
+     * than SYNTAX_MAX_LINE_LENGTH.
+     */
+    _anyLineTooLong(diff) {
+      if (!diff) return false;
+      return diff.content.some(section => {
+        const lines = section.ab ?
+          section.ab :
+          (section.a || []).concat(section.b || []);
+        return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+      });
+    },
+
+    _listenToViewportRender() {
+      const renderUpdateListener = start => {
+        if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
+          this.$.reporting.diffViewDisplayed();
+          this.$.syntaxLayer.removeListener(renderUpdateListener);
+        }
+      };
+
+      this.$.syntaxLayer.addListener(renderUpdateListener);
+    },
+
     _handleRenderStart() {
       this.$.reporting.time(TimingLabel.TOTAL);
       this.$.reporting.time(TimingLabel.CONTENT);
@@ -884,12 +986,7 @@
 
     _handleRenderContent() {
       this.$.reporting.timeEnd(TimingLabel.CONTENT);
-      this.$.reporting.time(TimingLabel.SYNTAX);
-    },
-
-    _handleRenderSyntax() {
-      this.$.reporting.timeEnd(TimingLabel.SYNTAX);
-      this.$.reporting.timeEnd(TimingLabel.TOTAL);
+      this.$.reporting.diffViewContentDisplayed();
     },
 
     _handleNormalizeRange(event) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index 6e7c239..94b2f7d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff-host.html">
@@ -49,7 +51,6 @@
         time: sandbox.stub(),
         timeEnd: sandbox.stub(),
       });
-
       element = fixture('basic');
     });
 
@@ -57,6 +58,22 @@
       sandbox.restore();
     });
 
+
+    suite('plugin layers', () => {
+      const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        stub('gr-js-api-interface', {
+          getDiffLayers() { return pluginLayers; },
+        });
+        element = fixture('basic');
+      });
+      test('plugin layers requested', () => {
+        element.patchRange = {};
+        element.reload();
+        assert(element.$.jsAPI.getDiffLayers.called);
+      });
+    });
+
     suite('handle comment-update', () => {
       setup(() => {
         sandbox.stub(element, '_commentsChanged');
@@ -250,7 +267,15 @@
       element.path = 'some/path';
       element.projectName = 'Some project';
       const threadEls = threads.map(
-          thread => element._createThreadElement(thread));
+          thread => {
+            const threadEl = element._createThreadElement(thread);
+            // Polymer 2 doesn't fire ready events and doesn't execute
+            // observers if element is not added to the Dom.
+            // See https://github.com/Polymer/old-docs-site/issues/2322
+            // and https://github.com/Polymer/polymer/issues/4526
+            element._attachThreadElement(threadEl);
+            return threadEl;
+          });
       assert.equal(threadEls.length, 2);
       assert.equal(threadEls[0].rootId, 4711);
       assert.equal(threadEls[1].rootId, 42);
@@ -269,7 +294,7 @@
     suite('render reporting', () => {
       test('starts total and content timer on render-start', done => {
         element.dispatchEvent(
-            new CustomEvent('render-start', {bubbles: true}));
+            new CustomEvent('render-start', {bubbles: true, composed: true}));
         assert.isTrue(element.$.reporting.time.calledWithExactly(
             'Diff Total Render'));
         assert.isTrue(element.$.reporting.time.calledWithExactly(
@@ -277,24 +302,82 @@
         done();
       });
 
-      test('ends content and starts syntax timer on render-content', done => {
+      test('ends content timer on render-content', () => {
         element.dispatchEvent(
-            new CustomEvent('render-content', {bubbles: true}));
-        assert.isTrue(element.$.reporting.time.calledWithExactly(
-            'Diff Syntax Render'));
+            new CustomEvent('render-content', {bubbles: true, composed: true}));
         assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
             'Diff Content Render'));
-        done();
       });
 
-      test('ends total and syntax timer on render-syntax', done => {
-        element.dispatchEvent(
-            new CustomEvent('render-syntax', {bubbles: true}));
-        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-            'Diff Total Render'));
-        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-            'Diff Syntax Render'));
-        done();
+      test('ends total and syntax timer after syntax layer processing', done => {
+        let notifySyntaxProcessed;
+        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+            resolve => {
+              notifySyntaxProcessed = resolve;
+            }));
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
+            Promise.resolve({content: []}));
+        element.patchRange = {};
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          return element.reload();
+        });
+        // Multiple cascading microtasks are scheduled.
+        setTimeout(() => {
+          notifySyntaxProcessed();
+          // Assert after the notification task is processed.
+          Promise.resolve().then(() => {
+            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+                'Diff Total Render'));
+            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+                'Diff Syntax Render'));
+            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+                'StartupDiffViewOnlyContent'));
+            done();
+          });
+        });
+      });
+
+      test('ends total timer w/ no syntax layer processing', done => {
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
+            Promise.resolve({content: []}));
+        element.patchRange = {};
+        element.reload();
+        // Multiple cascading microtasks are scheduled.
+        setTimeout(() => {
+          assert.isTrue(element.$.reporting.timeEnd.calledOnce);
+          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+              'Diff Total Render'));
+          done();
+        });
+      });
+
+      test('completes reload promise after syntax layer processing', done => {
+        let notifySyntaxProcessed;
+        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+            resolve => {
+              notifySyntaxProcessed = resolve;
+            }));
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
+            Promise.resolve({content: []}));
+        element.patchRange = {};
+        let reloadComplete = false;
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          return element.reload();
+        }).then(() => {
+          reloadComplete = true;
+        });
+        // Multiple cascading microtasks are scheduled.
+        setTimeout(() => {
+          assert.isFalse(reloadComplete);
+          notifySyntaxProcessed();
+          // Assert after the notification task is processed.
+          setTimeout(() => {
+            assert.isTrue(reloadComplete);
+            done();
+          });
+        });
       });
     });
 
@@ -303,6 +386,7 @@
 
       // Stub the network calls into requests that never resolve.
       sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+      element.patchRange = {};
 
       element.reload();
       assert.isTrue(cancelStub.called);
@@ -366,6 +450,7 @@
             (changeNum, basePatchNum, patchNum, path, onErr) => {
               onErr(error);
             });
+        element.patchRange = {};
         return element.reload().then(() => {
           assert.isTrue(onErrStub.calledOnce);
         });
@@ -723,6 +808,7 @@
 
     test('delegates cancel()', () => {
       const stub = sandbox.stub(element.$.diff, 'cancel');
+      element.patchRange = {};
       element.reload();
       assert.isTrue(stub.calledOnce);
       assert.equal(stub.lastCall.args.length, 0);
@@ -1241,9 +1327,11 @@
 
       const l = document.createElement('div');
       l.setAttribute('comment-side', 'left');
+      l.setAttribute('line-num', 'FILE');
 
       const r = document.createElement('div');
       r.setAttribute('comment-side', 'right');
+      r.setAttribute('line-num', 'FILE');
 
       const threadEls = [l, r];
       assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
@@ -1256,86 +1344,154 @@
           Gerrit.DiffSide.RIGHT), [r]);
     });
 
-    suite('_translateChunksToIgnore', () => {
-      let content;
-
+    suite('syntax layer with syntax_highlighting on', () => {
       setup(() => {
-        content = [
-          {ab: ['one', 'two']},
-          {a: ['three'], b: ['different three']},
-          {b: ['four']},
-          {ab: ['five', 'six']},
-          {a: ['seven']},
-          {ab: ['eight', 'nine']},
-        ];
+        const prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+          syntax_highlighting: true,
+        };
+        element.patchRange = {};
+        element.prefs = prefs;
       });
 
-      test('does nothing to unmarked diff', () => {
-        assert.deepEqual(element._translateChunksToIgnore({content}),
-            {content});
+      test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
+        element.reload();
+        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
       });
 
-      test('merges marked delta chunk', () => {
-        content[1].common = true;
-        assert.deepEqual(element._translateChunksToIgnore({content}), {
-          content: [
-            {ab: ['one', 'two', 'different three']},
-            {b: ['four']},
-            {ab: ['five', 'six']},
-            {a: ['seven']},
-            {ab: ['eight', 'nine']},
-          ],
+      test('rendering normal-sized diff does not disable syntax', () => {
+        element.diff = {
+          content: [{
+            a: ['foo'],
+          }],
+        };
+        assert.isTrue(element.$.syntaxLayer.enabled);
+      });
+
+      test('rendering large diff disables syntax', () => {
+        // Before it renders, set the first diff line to 500 '*' characters.
+        element.diff = {
+          content: [{
+            a: [new Array(501).join('*')],
+          }],
+        };
+        assert.isFalse(element.$.syntaxLayer.enabled);
+      });
+
+      test('starts syntax layer processing on render event', done => {
+        sandbox.stub(element.$.syntaxLayer, 'process').returns(Promise.resolve());
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
+            Promise.resolve({content: []}));
+        element.reload();
+        setTimeout(() => {
+          element.dispatchEvent(
+              new CustomEvent('render', {bubbles: true, composed: true}));
+          assert.isTrue(element.$.syntaxLayer.process.called);
+          done();
+        });
+      });
+    });
+
+    suite('syntax layer with syntax_highlgihting off', () => {
+      setup(() => {
+        const prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+        };
+        element.diff = {
+          content: [{
+            a: ['foo'],
+          }],
+        };
+        element.patchRange = {};
+        element.prefs = prefs;
+      });
+
+      test('gr-diff-host provides syntax highlighting layer', () => {
+        element.reload();
+        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+      });
+
+      test('syntax layer should be disabled', () => {
+        assert.isFalse(element.$.syntaxLayer.enabled);
+      });
+
+      test('still disabled for large diff', () => {
+        // Before it renders, set the first diff line to 500 '*' characters.
+        element.diff = {
+          content: [{
+            a: [new Array(501).join('*')],
+          }],
+        };
+        assert.isFalse(element.$.syntaxLayer.enabled);
+      });
+    });
+
+    suite('coverage layer', () => {
+      let notifyStub;
+      setup(() => {
+        notifyStub = sinon.stub();
+        stub('gr-js-api-interface', {
+          getCoverageAnnotationApi() {
+            return Promise.resolve({
+              notify: notifyStub,
+              getCoverageProvider() {
+                return () => Promise.resolve([
+                  {
+                    type: 'COVERED',
+                    side: 'right',
+                    code_range: {
+                      start_line: 1,
+                      end_line: 2,
+                    },
+                  },
+                  {
+                    type: 'NOT_COVERED',
+                    side: 'right',
+                    code_range: {
+                      start_line: 3,
+                      end_line: 4,
+                    },
+                  },
+                ]);
+              },
+            });
+          },
+        });
+        element = fixture('basic');
+        const prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+        };
+        element.diff = {
+          content: [{
+            a: ['foo'],
+          }],
+        };
+        element.patchRange = {};
+        element.prefs = prefs;
+      });
+
+      test('getCoverageAnnotationApi should be called', done => {
+        element.reload();
+        flush(() => {
+          assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+          done();
         });
       });
 
-      test('merges marked addition chunk', () => {
-        content[2].common = true;
-        assert.deepEqual(element._translateChunksToIgnore({content}), {
-          content: [
-            {ab: ['one', 'two']},
-            {a: ['three'], b: ['different three']},
-            {ab: ['four', 'five', 'six']},
-            {a: ['seven']},
-            {ab: ['eight', 'nine']},
-          ],
-        });
-      });
-
-      test('merges multiple marked delta', () => {
-        content[1].common = true;
-        content[2].common = true;
-        assert.deepEqual(element._translateChunksToIgnore({content}), {
-          content: [
-            {ab: ['one', 'two', 'different three', 'four', 'five', 'six']},
-            {a: ['seven']},
-            {ab: ['eight', 'nine']},
-          ],
-        });
-      });
-
-      test('marked deletion chunks are omitted', () => {
-        content[4].common = true;
-        assert.deepEqual(element._translateChunksToIgnore({content}), {
-          content: [
-            {ab: ['one', 'two']},
-            {a: ['three'], b: ['different three']},
-            {b: ['four']},
-            {ab: ['five', 'six', 'eight', 'nine']},
-          ],
-        });
-      });
-
-      test('marked deltas can start shared chunks', () => {
-        content[0] = {a: ['one'], b: ['two'], common: true};
-        assert.deepEqual(element._translateChunksToIgnore({content}), {
-          content: [
-            {ab: ['two']},
-            {a: ['three'], b: ['different three']},
-            {b: ['four']},
-            {ab: ['five', 'six']},
-            {a: ['seven']},
-            {ab: ['eight', 'nine']},
-          ],
+      test('coverageRangeChanged should be called', done => {
+        element.reload();
+        flush(() => {
+          assert.equal(notifyStub.callCount, 2);
+          done();
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
index 8251e53..47cf771 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -41,7 +42,7 @@
         has-tooltip
         class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
         title="Side-by-side diff"
-        on-tap="_handleSideBySideTap">
+        on-click="_handleSideBySideTap">
       <iron-icon icon="gr-icons:side-by-side"></iron-icon>
     </gr-button>
     <gr-button
@@ -50,7 +51,7 @@
         has-tooltip
         title="Unified diff"
         class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
-        on-tap="_handleUnifiedTap">
+        on-click="_handleUnifiedTap">
       <iron-icon icon="gr-icons:unified"></iron-icon>
     </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
index e2d6a28..88dd91a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-diff-mode-selector',
-    _legacyUndefinedCheck: true,
 
     properties: {
       mode: {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
index c011106..adeaa15 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-mode-selector</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-diff-mode-selector.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
index b850f2c..b0167b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.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-button/gr-button.html">
 <link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
@@ -26,7 +27,7 @@
     <style include="shared-styles">
       .diffHeader,
       .diffActions {
-        padding: 1em 1.5em;
+        padding: var(--spacing-l) var(--spacing-xl);
       }
       .diffHeader,
       .diffActions {
@@ -42,7 +43,7 @@
         justify-content: flex-end;
       }
       .diffPrefsOverlay gr-button {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       div.edited:after {
         color: var(--deemphasized-text-color);
@@ -50,7 +51,7 @@
       }
       #diffPreferences {
         display: flex;
-        padding: .35em 1.5em;
+        padding: var(--spacing-s) var(--spacing-xl);
       }
     </style>
     <gr-overlay id="diffPrefsOverlay" with-backdrop>
@@ -63,13 +64,13 @@
         <gr-button
             id="cancelButton"
             link
-            on-tap="_handleCancelDiff">
+            on-click="_handleCancelDiff">
             Cancel
         </gr-button>
         <gr-button
             id="saveButton"
             link primary
-            on-tap="_handleSaveDiffPreferences"
+            on-click="_handleSaveDiffPreferences"
             disabled$="[[!_diffPrefsChanged]]">
             Save
         </gr-button>
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 7f7cd73..9b723bd 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
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-diff-preferences-dialog',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
@@ -39,15 +38,19 @@
       _diffPrefsChanged: Boolean,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     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-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
index 663cf25..922ac87 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-diff-processor">
   <script src="../gr-diff/gr-diff-line.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 2cc69e6..e052a8f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -24,12 +24,6 @@
     RIGHT: 'right',
   };
 
-  const DiffGroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
   const DiffHighlights = {
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
@@ -48,19 +42,30 @@
   /**
    * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
    *
-   * This includes a number of tasks:
+   * Glossary:
+   * - "chunk": A single `DiffContent` as returned by the API.
+   * - "group": A single `GrDiffGroup` as used for rendering.
+   * - "common" chunk/group: A chunk/group that should be considered unchanged
+   *   for diffing purposes. This can mean its either actually unchanged, or it
+   *   has only whitespace changes.
+   * - "key location": A line number and side of the diff that should not be
+   *   collapsed e.g. because a comment is attached to it, or because it was
+   *   provided in the URL and thus should be visible
+   * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+   *   or cannot be collapsed because it contains a key location
+   *
+   * Here a a number of tasks this processor performs:
+   *  - splitting large chunks to allow more granular async rendering
    *  - adding a group for the "File" pseudo line that file-level comments can
    *    be attached to
-   *  - replacing unchanged parts of the diff that are outside the user's
+   *  - replacing common parts of the diff that are outside the user's
    *    context setting and do not have comments with a group representing the
-   *    "expand context" widget. This may require splitting a `DiffContent` so
+   *    "expand context" widget. This may require splitting a chunk/group so
    *    that the part that is within the context or has comments is shown, while
    *    the rest is not.
-   *  - splitting large `DiffContent`s to allow more granular async rendering
    */
   Polymer({
     is: 'gr-diff-processor',
-    _legacyUndefinedCheck: true,
 
     properties: {
 
@@ -127,13 +132,16 @@
     },
 
     /**
-     * Asynchronously process the diff object into groups. As it processes, it
+     * Asynchronously process the diff chunks into groups. As it processes, it
      * will splice groups into the `groups` property of the component.
      *
-     * @return {Promise} A promise that resolves when the diff is completely
-     *     processed.
+     * @param {!Array<!Gerrit.DiffChunk>} chunks
+     * @param {boolean} isBinary
+     *
+     * @return {!Promise<!Array<!Object>>} A promise that resolves with an
+     *     array of GrDiffGroups when the diff is completely processed.
      */
-    process(content, isBinary) {
+    process(chunks, isBinary) {
       // Cancel any still running process() calls, because they append to the
       // same groups field.
       this.cancel();
@@ -150,11 +158,11 @@
           new Promise(resolve => {
             const state = {
               lineNums: {left: 0, right: 0},
-              sectionIndex: 0,
+              chunkIndex: 0,
             };
 
-            content = this._splitLargeChunks(content);
-            content = this._splitUnchangedChunksWithComments(content);
+            chunks = this._splitLargeChunks(chunks);
+            chunks = this._splitCommonChunksWithKeyLocations(chunks);
 
             let currentBatch = 0;
             const nextStep = () => {
@@ -163,23 +171,23 @@
                 return;
               }
               // If we are done, resolve the promise.
-              if (state.sectionIndex >= content.length) {
-                resolve(this.groups);
+              if (state.chunkIndex >= chunks.length) {
+                resolve();
                 this._nextStepHandle = null;
                 return;
               }
 
-              // Process the next section and incorporate the result.
-              const result = this._processNext(state, content);
-              for (const group of result.groups) {
+              // Process the next chunk and incorporate the result.
+              const stateUpdate = this._processNext(state, chunks);
+              for (const group of stateUpdate.groups) {
                 this.push('groups', group);
                 currentBatch += group.lines.length;
               }
-              state.lineNums.left += result.lineDelta.left;
-              state.lineNums.right += result.lineDelta.right;
+              state.lineNums.left += stateUpdate.lineDelta.left;
+              state.lineNums.right += stateUpdate.lineDelta.right;
 
               // Increment the index and recurse.
-              state.sectionIndex++;
+              state.chunkIndex = stateUpdate.newChunkIndex;
               if (currentBatch >= this._asyncThreshold) {
                 currentBatch = 0;
                 this._nextStepHandle = this.async(nextStep, 1);
@@ -208,180 +216,199 @@
     },
 
     /**
-     * Process the next section of the diff.
+     * Process the next uncollapsible chunk, or the next collapsible chunks.
+     *
+     * @param {!Object} state
+     * @param {!Array<!Object>} chunks
+     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
      */
-    _processNext(state, content) {
-      const section = content[state.sectionIndex];
-
-      const rows = {
-        both: section[DiffGroupType.BOTH] || null,
-        added: section[DiffGroupType.ADDED] || null,
-        removed: section[DiffGroupType.REMOVED] || null,
-      };
-
-      const highlights = {
-        added: section[DiffHighlights.ADDED] || null,
-        removed: section[DiffHighlights.REMOVED] || null,
-      };
-
-      if (rows.both) { // If it's a shared section.
-        let sectionEnd = null;
-        if (state.sectionIndex === 0) {
-          sectionEnd = 'first';
-        } else if (state.sectionIndex === content.length - 1) {
-          sectionEnd = 'last';
-        }
-
-        const sharedGroups = this._sharedGroupsFromRows(
-            rows.both,
-            content.length > 1 ? this.context : WHOLE_FILE,
-            state.lineNums.left,
-            state.lineNums.right,
-            sectionEnd);
-
+    _processNext(state, chunks) {
+      const firstUncollapsibleChunkIndex =
+          this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
+      if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+        const chunk = chunks[state.chunkIndex];
         return {
           lineDelta: {
-            left: rows.both.length,
-            right: rows.both.length,
+            left: this._linesLeft(chunk).length,
+            right: this._linesRight(chunk).length,
           },
-          groups: sharedGroups,
-        };
-      } else { // Otherwise it's a delta section.
-        const deltaGroup = this._deltaGroupFromRows(
-            rows.added,
-            rows.removed,
-            state.lineNums.left,
-            state.lineNums.right,
-            highlights);
-        deltaGroup.dueToRebase = section.due_to_rebase;
-
-        return {
-          lineDelta: {
-            left: rows.removed ? rows.removed.length : 0,
-            right: rows.added ? rows.added.length : 0,
-          },
-          groups: [deltaGroup],
+          groups: [this._chunkToGroup(
+              chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
+          newChunkIndex: state.chunkIndex + 1,
         };
       }
+
+      return this._processCollapsibleChunks(
+          state, chunks, firstUncollapsibleChunkIndex);
+    },
+
+    _linesLeft(chunk) {
+      return chunk.ab || chunk.a || [];
+    },
+
+    _linesRight(chunk) {
+      return chunk.ab || chunk.b || [];
+    },
+
+    _firstUncollapsibleChunkIndex(chunks, offset) {
+      let chunkIndex = offset;
+      while (chunkIndex < chunks.length &&
+          this._isCollapsibleChunk(chunks[chunkIndex])) {
+        chunkIndex++;
+      }
+      return chunkIndex;
+    },
+
+    _isCollapsibleChunk(chunk) {
+      return (chunk.ab || chunk.common) && !chunk.keyLocation;
     },
 
     /**
-     * Take rows of a shared diff section and produce an array of corresponding
-     * (potentially collapsed) groups.
+     * Process a stretch of collapsible chunks.
      *
-     * @param {!Array<string>} rows
-     * @param {number} context
-     * @param {number} startLineNumLeft
-     * @param {number} startLineNumRight
-     * @param {?string=} opt_sectionEnd String representing whether this is the
-     *     first section or the last section or neither. Use the values 'first',
-     *     'last' and null respectively.
-     * @return {!Array<!Object>} Array of GrDiffGroup
+     * Outputs up to three groups:
+     *  1) Visible context before the hidden common code, unless it's the
+     *     very beginning of the file.
+     *  2) Context hidden behind a context bar, unless empty.
+     *  3) Visible context after the hidden common code, unless it's the very
+     *     end of the file.
+     *
+     * @param {!Object} state
+     * @param {!Array<Object>} chunks
+     * @param {number} firstUncollapsibleChunkIndex
+     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
      */
-    _sharedGroupsFromRows(rows, context, startLineNumLeft,
-        startLineNumRight, opt_sectionEnd) {
-      const result = [];
-      const lines = [];
-      let line;
+    _processCollapsibleChunks(
+        state, chunks, firstUncollapsibleChunkIndex) {
+      const collapsibleChunks = chunks.slice(
+          state.chunkIndex, firstUncollapsibleChunkIndex);
+      const lineCount = collapsibleChunks.reduce(
+          (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
 
-      // Map each row to a GrDiffLine.
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.text = rows[i];
-        line.beforeNumber = ++startLineNumLeft;
-        line.afterNumber = ++startLineNumRight;
-        lines.push(line);
+      let groups = this._chunksToGroups(
+          collapsibleChunks,
+          state.lineNums.left + 1,
+          state.lineNums.right + 1);
+
+      if (this.context !== WHOLE_FILE) {
+        const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
+        const hiddenEnd = lineCount - (
+          firstUncollapsibleChunkIndex === chunks.length ?
+            0 : this.context);
+        groups = GrDiffGroup.hideInContextControl(
+            groups, hiddenStart, hiddenEnd);
       }
 
-      // Find the hidden range based on the user's context preference. If this
-      // is the first or the last section of the diff, make sure the collapsed
-      // part of the section extends to the edge of the file.
-      const hiddenRange = [context, rows.length - context];
-      if (opt_sectionEnd === 'first') {
-        hiddenRange[0] = 0;
-      } else if (opt_sectionEnd === 'last') {
-        hiddenRange[1] = rows.length;
-      }
+      return {
+        lineDelta: {
+          left: lineCount,
+          right: lineCount,
+        },
+        groups,
+        newChunkIndex: firstUncollapsibleChunkIndex,
+      };
+    },
 
-      // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
-        const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-        const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-        const linesAfterCtx = lines.slice(hiddenRange[1]);
-
-        if (linesBeforeCtx.length > 0) {
-          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
-        }
-
-        const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-        ctxLine.contextGroup =
-            new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
-        result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
-            [ctxLine]));
-
-        if (linesAfterCtx.length > 0) {
-          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
-        }
-      } else {
-        result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
-      }
-
-      return result;
+    _commonChunkLength(chunk) {
+      console.assert(chunk.ab || chunk.common);
+      console.assert(
+          !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
+          `common chunk needs same number of a and b lines: `, chunk);
+      return this._linesLeft(chunk).length;
     },
 
     /**
-     * Take the rows of a delta diff section and produce the corresponding
-     * group.
-     *
-     * @param {!Array<string>} rowsAdded
-     * @param {!Array<string>} rowsRemoved
-     * @param {number} startLineNumLeft
-     * @param {number} startLineNumRight
-     * @return {!Object} (Gr-Diff-Group)
+     * @param {!Array<!Object>} chunks
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @return {!Array<!Object>} (GrDiffGroup)
      */
-    _deltaGroupFromRows(rowsAdded, rowsRemoved, startLineNumLeft,
-        startLineNumRight, highlights) {
+    _chunksToGroups(chunks, offsetLeft, offsetRight) {
+      return chunks.map(chunk => {
+        const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+        const chunkLength = this._commonChunkLength(chunk);
+        offsetLeft += chunkLength;
+        offsetRight += chunkLength;
+        return group;
+      });
+    },
+
+    /**
+     * @param {!Object} chunk
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @return {!Object} (GrDiffGroup)
+     */
+    _chunkToGroup(chunk, offsetLeft, offsetRight) {
+      const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
+      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+      const group = new GrDiffGroup(type, lines);
+      group.keyLocation = chunk.keyLocation;
+      group.dueToRebase = chunk.due_to_rebase;
+      group.ignoredWhitespaceOnly = chunk.common;
+      return group;
+    },
+
+    _linesFromChunk(chunk, offsetLeft, offsetRight) {
+      if (chunk.ab) {
+        return chunk.ab.map((row, i) => this._lineFromRow(
+            GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
+      }
       let lines = [];
-      if (rowsRemoved) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
-            rowsRemoved, startLineNumLeft, highlights.removed));
+      if (chunk.a) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
+            chunk[DiffHighlights.REMOVED]));
       }
-      if (rowsAdded) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.ADD,
-            rowsAdded, startLineNumRight, highlights.added));
-      }
-      return new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-    },
-
-    /**
-     * @return {!Array<!Object>} Array of GrDiffLines
-     */
-    _deltaLinesFromRows(lineType, rows, startLineNum,
-        opt_highlights) {
-      // Normalize highlights if they have been passed.
-      if (opt_highlights) {
-        opt_highlights = this._normalizeIntralineHighlights(rows,
-            opt_highlights);
-      }
-
-      const lines = [];
-      let line;
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(lineType);
-        line.text = rows[i];
-        if (lineType === GrDiffLine.Type.ADD) {
-          line.afterNumber = ++startLineNum;
-        } else {
-          line.beforeNumber = ++startLineNum;
-        }
-        if (opt_highlights) {
-          line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
-        }
-        lines.push(line);
+      if (chunk.b) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.ADD, chunk.b, offsetRight,
+            chunk[DiffHighlights.ADDED]));
       }
       return lines;
     },
 
+    /**
+     * @param {string} lineType (GrDiffLine.Type)
+     * @param {!Array<string>} rows
+     * @param {number} offset
+     * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
+     * @return {!Array<!Object>} (GrDiffLine)
+     */
+    _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
+      const grDiffHighlights = opt_intralineInfos ?
+        this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
+      return rows.map((row, i) => this._lineFromRow(
+          lineType, offset, offset, row, i, grDiffHighlights));
+    },
+
+    /**
+     * @param {string} type (GrDiffLine.Type)
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @param {string} row
+     * @param {number} i
+     * @param {!Array<!Object>=} opt_highlights
+     * @return {!Object} (GrDiffLine)
+     */
+    _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
+      const line = new GrDiffLine(type);
+      line.text = row;
+      if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
+      if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
+      if (opt_highlights) {
+        line.hasIntralineInfo = true;
+        line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+      } else {
+        line.hasIntralineInfo = false;
+      }
+      return line;
+    },
+
     _makeFileComments() {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
@@ -402,16 +429,16 @@
      * into 2 chunks, one max sized one and the rest (for reasons that are
      * unclear to me).
      *
-     * @param {!Array<!Object>} chunks Chunks as returned from the server
-     * @return {!Array<!Object>} Finer grained chunks.
+     * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
+     * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
      */
     _splitLargeChunks(chunks) {
       const newChunks = [];
 
       for (const chunk of chunks) {
         if (!chunk.ab) {
-          for (const group of this._breakdownGroup(chunk)) {
-            newChunks.push(group);
+          for (const subChunk of this._breakdownChunk(chunk)) {
+            newChunks.push(subChunk);
           }
           continue;
         }
@@ -421,7 +448,7 @@
         // enabled for any other context preference because manipulating the
         // chunks in this way violates assumptions by the context grouper logic.
         if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
-          // Split large shared groups in two, where the first is the maximum
+          // Split large shared chunks in two, where the first is the maximum
           // group size.
           newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
           newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
@@ -433,21 +460,21 @@
     },
 
     /**
-     * In order to show comments out of the bounds of the selected context,
-     * treat them as separate chunks within the model so that the content (and
-     * context surrounding it) renders correctly.
+     * In order to show key locations, such as comments, out of the bounds of
+     * the selected context, treat them as separate chunks within the model so
+     * that the content (and context surrounding it) renders correctly.
      *
      * @param {!Array<!Object>} chunks DiffContents as returned from server.
      * @return {!Array<!Object>} Finer grained DiffContents.
      */
-    _splitUnchangedChunksWithComments(chunks) {
+    _splitCommonChunksWithKeyLocations(chunks) {
       const result = [];
-      let leftLineNum = 0;
-      let rightLineNum = 0;
+      let leftLineNum = 1;
+      let rightLineNum = 1;
 
       for (const chunk of chunks) {
         // If it isn't a common chunk, append it as-is and update line numbers.
-        if (!chunk.ab) {
+        if (!chunk.ab && !chunk.common) {
           if (chunk.a) {
             leftLineNum += chunk.a.length;
           }
@@ -458,34 +485,26 @@
           continue;
         }
 
-        let currentChunk = {ab: []};
-
-        // For each line in the common group.
-        for (const line of chunk.ab) {
-          leftLineNum++;
-          rightLineNum++;
-
-          // If this line should not be collapsed.
-          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
-              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
-            // If any lines have been accumulated into the chunk leading up to
-            // this non-collapse line, then add them as a chunk and start a new
-            // one.
-            if (currentChunk.ab && currentChunk.ab.length > 0) {
-              result.push(currentChunk);
-              currentChunk = {ab: []};
-            }
-
-            // Add the non-collapse line as its own chunk.
-            result.push({ab: [line]});
-          } else {
-            // Append the current line to the current chunk.
-            currentChunk.ab.push(line);
-          }
+        if (chunk.common && chunk.a.length != chunk.b.length) {
+          throw new Error(
+              'DiffContent with common=true must always have equal length');
         }
+        const numLines = this._commonChunkLength(chunk);
+        const chunkEnds = this._findChunkEndsAtKeyLocations(
+            numLines, leftLineNum, rightLineNum);
+        leftLineNum += numLines;
+        rightLineNum += numLines;
 
-        if (currentChunk.ab && currentChunk.ab.length > 0) {
-          result.push(currentChunk);
+        if (chunk.ab) {
+          result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
+              .map(({lines, keyLocation}) =>
+                Object.assign({}, chunk, {ab: lines, keyLocation})));
+        } else if (chunk.common) {
+          const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
+          const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
+          result.push(...aChunks.map(({lines, keyLocation}, i) =>
+            Object.assign(
+                {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
         }
       }
 
@@ -493,52 +512,84 @@
     },
 
     /**
-     * The `highlights` array consists of a list of <skip length, mark length>
-     * pairs, where the skip length is the number of characters between the
-     * end of the previous edit and the start of this edit, and the mark
-     * length is the number of edited characters following the skip. The start
-     * of the edits is from the beginning of the related diff content lines.
-     *
-     * Note that the implied newline character at the end of each line is
-     * included in the length calculation, and thus it is possible for the
-     * edits to span newlines.
-     *
-     * A line highlight object consists of three fields:
-     * - contentIndex: The index of the diffChunk `content` field (the line
-     *   being referred to).
-     * - startIndex: Where the highlight should begin.
-     * - endIndex: (optional) Where the highlight should end. If omitted, the
-     *   highlight is meant to be a continuation onto the next line.
+     * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
+     *   new chunk ends, including whether it's a key location.
      */
-    _normalizeIntralineHighlights(content, highlights) {
-      let contentIndex = 0;
+    _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
+      const result = [];
+      let lastChunkEnd = 0;
+      for (let i=0; i<numLines; i++) {
+        // If this line should not be collapsed.
+        if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
+            this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
+          // If any lines have been accumulated into the chunk leading up to
+          // this non-collapse line, then add them as a chunk and start a new
+          // one.
+          if (i > lastChunkEnd) {
+            result.push({offset: i, keyLocation: false});
+            lastChunkEnd = i;
+          }
+
+          // Add the non-collapse line as its own chunk.
+          result.push({offset: i + 1, keyLocation: true});
+        }
+      }
+
+      if (numLines > lastChunkEnd) {
+        result.push({offset: numLines, keyLocation: false});
+      }
+
+      return result;
+    },
+
+    _splitAtChunkEnds(lines, chunkEnds) {
+      const result = [];
+      let lastChunkEndOffset = 0;
+      for (const {offset, keyLocation} of chunkEnds) {
+        result.push(
+            {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
+        lastChunkEndOffset = offset;
+      }
+      return result;
+    },
+
+    /**
+     * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+     * for rendering.
+     *
+     * @param {!Array<string>} rows
+     * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
+     * @return {!Array<!Object>} (GrDiffLine.Highlight)
+     */
+    _convertIntralineInfos(rows, intralineInfos) {
+      let rowIndex = 0;
       let idx = 0;
       const normalized = [];
-      for (const hl of highlights) {
-        let line = content[contentIndex] + '\n';
+      for (const [skipLength, markLength] of intralineInfos) {
+        let line = rows[rowIndex] + '\n';
         let j = 0;
-        while (j < hl[0]) {
+        while (j < skipLength) {
           if (idx === line.length) {
             idx = 0;
-            line = content[++contentIndex] + '\n';
+            line = rows[++rowIndex] + '\n';
             continue;
           }
           idx++;
           j++;
         }
         let lineHighlight = {
-          contentIndex,
+          contentIndex: rowIndex,
           startIndex: idx,
         };
 
         j = 0;
-        while (line && j < hl[1]) {
+        while (line && j < markLength) {
           if (idx === line.length) {
             idx = 0;
-            line = content[++contentIndex] + '\n';
+            line = rows[++rowIndex] + '\n';
             normalized.push(lineHighlight);
             lineHighlight = {
-              contentIndex,
+              contentIndex: rowIndex,
               startIndex: idx,
             };
             continue;
@@ -554,32 +605,32 @@
 
     /**
      * If a group is an addition or a removal, break it down into smaller groups
-     * of that type using the MAX_GROUP_SIZE. If the group is a shared section
+     * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
      * or a delta it is returned as the single element of the result array.
      *
-     * @param {!Object} group A raw chunk from a diff response.
+     * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
      * @return {!Array<!Array<!Object>>}
      */
-    _breakdownGroup(group) {
+    _breakdownChunk(chunk) {
       let key = null;
-      if (group.a && !group.b) {
+      if (chunk.a && !chunk.b) {
         key = 'a';
-      } else if (group.b && !group.a) {
+      } else if (chunk.b && !chunk.a) {
         key = 'b';
-      } else if (group.ab) {
+      } else if (chunk.ab) {
         key = 'ab';
       }
 
-      if (!key) { return [group]; }
+      if (!key) { return [chunk]; }
 
-      return this._breakdown(group[key], MAX_GROUP_SIZE)
-          .map(subgroupLines => {
-            const subGroup = {};
-            subGroup[key] = subgroupLines;
-            if (group.due_to_rebase) {
-              subGroup.due_to_rebase = true;
+      return this._breakdown(chunk[key], MAX_GROUP_SIZE)
+          .map(subChunkLines => {
+            const subChunk = {};
+            subChunk[key] = subChunkLines;
+            if (chunk.due_to_rebase) {
+              subChunk.due_to_rebase = true;
             }
-            return subGroup;
+            return subChunk;
           });
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 186a49e..c04b066 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-processor test</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff-processor.html">
 
@@ -60,7 +62,7 @@
         element.context = 4;
       });
 
-      test('process loaded content', done => {
+      test('process loaded content', () => {
         const content = [
           {
             ab: [
@@ -86,7 +88,7 @@
           },
         ];
 
-        element.process(content).then(() => {
+        return element.process(content).then(() => {
           const groups = element.groups;
 
           assert.equal(groups.length, 4);
@@ -139,31 +141,15 @@
             'everyone pretend to shower.',
             'Fry: Same as every day. Got it.',
           ]);
-
-          done();
         });
       });
 
-      test('insert context groups', done => {
+      test('first group is for file', () => {
         const content = [
-          {ab: []},
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: []},
-          {b: ['elgoog elgoog elgoog']},
-          {ab: []},
+          {b: ['foo']},
         ];
-        for (let i = 0; i < 100; i++) {
-          content[0].ab.push('all work and no play make jack a dull boy');
-          content[4].ab.push('all work and no play make jill a dull girl');
-        }
-        for (let i = 0; i < 5; i++) {
-          content[2].ab.push('no tv and no beer make homer go crazy');
-        }
 
-        const context = 10;
-        element.context = context;
-
-        element.process(content).then(() => {
+        return element.process(content).then(() => {
           const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
@@ -171,107 +157,278 @@
           assert.equal(groups[0].lines[0].text, '');
           assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
           assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
-
-          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[1].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[0].ab[0]);
-          }
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, context);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, content[0].ab[0]);
-          }
-
-          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[3].lines.length, 1);
-          assert.equal(groups[3].removes.length, 1);
-          assert.equal(groups[3].removes[0].text,
-              'all work and no play make andybons a dull boy');
-
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, 5);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, content[2].ab[0]);
-          }
-
-          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[5].lines.length, 1);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
-
-          assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[6].lines.length, context);
-          for (const l of groups[6].lines) {
-            assert.equal(l.text, content[4].ab[0]);
-          }
-
-          assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[7].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[4].ab[0]);
-          }
-
-          done();
         });
       });
 
-      test('insert context groups', done => {
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: []},
-          {b: ['elgoog elgoog elgoog']},
-        ];
-        for (let i = 0; i < 50; i++) {
-          content[1].ab.push('no tv and no beer make homer go crazy');
-        }
+      suite('context groups', () => {
+        test('at the beginning, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {ab: new Array(100)
+                .fill('all work and no play make jack a dull boy')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
 
-        const context = 10;
-        element.context = context;
+          return element.process(content).then(() => {
+            const groups = element.groups;
 
-        element.process(content).then(() => {
-          const groups = element.groups;
+            // group[0] is the file group
 
-          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[0].lines.length, 1);
-          assert.equal(groups[0].lines[0].text, '');
-          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+            assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
+            for (const l of groups[1].lines[0].contextGroups[0].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
 
-          assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[1].lines.length, 1);
-          assert.equal(groups[1].removes.length, 1);
-          assert.equal(groups[1].removes[0].text,
-              'all work and no play make andybons a dull boy');
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
+          });
+        });
 
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, context);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+        test('at the beginning, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {ab: new Array(5)
+                .fill('all work and no play make jack a dull boy')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
 
-          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-          for (const l of groups[3].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+          return element.process(content).then(() => {
+            const groups = element.groups;
 
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, context);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+            // group[0] is the file group
 
-          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[5].lines.length, 1);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+            assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[1].lines.length, 5);
+            for (const l of groups[1].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
+          });
+        });
 
-          done();
+        test('at the end, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(100)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
+            for (const l of groups[3].lines[0].contextGroups[0].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('at the end, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(5)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 5);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('for interleaved ab and common: true chunks', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+            {
+              a: new Array(3).fill(
+                  'all work and no play make jill a dull girl'),
+              b: new Array(3).fill(
+                  '  all work and no play make jill a dull girl'),
+              common: true,
+            },
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+            {
+              a: new Array(3).fill(
+                  'all work and no play make jill a dull girl'),
+              b: new Array(3).fill(
+                  '  all work and no play make jill a dull girl'),
+              common: true,
+            },
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            // The first three interleaved chunks are completely shown because
+            // they are part of the context (3 * 3 <= 10)
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 3);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+            assert.equal(groups[3].lines.length, 6);
+            assert.equal(groups[3].adds.length, 3);
+            assert.equal(groups[3].removes.length, 3);
+            for (const l of groups[3].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[3].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[4].lines.length, 3);
+            for (const l of groups[4].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            // The next chunk is partially shown, so it results in two groups
+
+            assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+            assert.equal(groups[5].lines.length, 2);
+            assert.equal(groups[5].adds.length, 1);
+            assert.equal(groups[5].removes.length, 1);
+            for (const l of groups[5].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[5].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.equal(groups[6].lines[0].contextGroups.length, 2);
+
+            assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
+            assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
+            assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
+            for (const l of groups[6].lines[0].contextGroups[0].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[6].lines[0].contextGroups[0].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            // The final chunk is completely hidden
+            assert.equal(
+                groups[6].lines[0].contextGroups[1].type,
+                GrDiffGroup.Type.BOTH);
+            assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
+            for (const l of groups[6].lines[0].contextGroups[1].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('in the middle, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(100)
+                .fill('all work and no play make jill a dull girl')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
+            for (const l of groups[3].lines[0].contextGroups[0].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[4].lines.length, 10);
+            for (const l of groups[4].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('in the middle, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(5)
+                .fill('all work and no play make jill a dull girl')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 5);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
         });
       });
 
@@ -303,10 +460,11 @@
           },
         ];
         const result =
-            element._splitUnchangedChunksWithComments(content);
+            element._splitCommonChunksWithKeyLocations(content);
         assert.deepEqual(result, [
           {
             ab: ['Copyright (C) 2015 The Android Open Source Project'],
+            keyLocation: true,
           },
           {
             ab: [
@@ -320,10 +478,12 @@
               '',
               'Unless required by applicable law or agreed to in writing, ',
             ],
+            keyLocation: false,
           },
           {
             ab: [
               'software distributed under the License is distributed on an '],
+            keyLocation: true,
           },
           {
             ab: [
@@ -332,6 +492,7 @@
               'language governing permissions and limitations under the ' +
                   'License.',
             ],
+            keyLocation: false,
           },
         ]);
       });
@@ -348,14 +509,16 @@
         assert.deepEqual(result[1].ab, content[0].ab.slice(120));
       });
 
-      test('does not break-down shared chunks w/ context', () => {
+      test('does not break-down common chunks w/ context', () => {
         const content = [{
           ab: _.times(75, () => { return `${Math.random()}`; }),
         }];
         element.context = 4;
         const result =
-            element._splitUnchangedChunksWithComments(content);
-        assert.deepEqual(result, content);
+            element._splitCommonChunksWithKeyLocations(content);
+        assert.equal(result.length, 1);
+        assert.deepEqual(result[0].ab, content[0].ab);
+        assert.isFalse(result[0].keyLocation);
       });
 
       test('intraline normalization', () => {
@@ -371,7 +534,7 @@
           [31, 34], [42, 26],
         ];
 
-        let results = element._normalizeIntralineHighlights(content,
+        let results = element._convertIntralineInfos(content,
             highlights);
         assert.deepEqual(results, [
           {
@@ -412,7 +575,7 @@
           [12, 67],
           [14, 29],
         ];
-        results = element._normalizeIntralineHighlights(content, highlights);
+        results = element._convertIntralineInfos(content, highlights);
         assert.deepEqual(results, [
           {
             contentIndex: 0,
@@ -453,10 +616,13 @@
         sandbox.stub(element, 'async');
         element._isScrolling = true;
         element.process(content);
+        // Just the files group - no more processing during scrolling.
         assert.equal(element.groups.length, 1);
+
         element._isScrolling = false;
         element.process(content);
-        assert.equal(element.groups.length, 33);
+        // More groups have been processed. How many does not matter here.
+        assert.isAtLeast(element.groups.length, 2);
       });
 
       test('image diffs', () => {
@@ -475,6 +641,198 @@
         assert.equal(element.groups[0].lines.length, 1);
       });
 
+      suite('_processNext', () => {
+        let rows;
+
+        setup(() => {
+          rows = loremIpsum.split(' ');
+        });
+
+        test('WHOLE_FILE', () => {
+          element.context = WHOLE_FILE;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one, uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(result.groups[0].lines.length, rows.length);
+
+          // Line numbers are set correctly.
+          assert.equal(
+              result.groups[0].lines[0].beforeNumber,
+              state.lineNums.left + 1);
+          assert.equal(
+              result.groups[0].lines[0].afterNumber,
+              state.lineNums.right + 1);
+
+          assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+              state.lineNums.left + rows.length);
+          assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+              state.lineNums.right + rows.length);
+        });
+
+        test('with context', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+          const expectedCollapseSize = rows.length - 2 * element.context;
+
+          assert.equal(result.groups.length, 3, 'Results in three groups');
+
+          // The first and last are uncollapsed context, whereas the middle has
+          // a single context-control line.
+          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, 1);
+          assert.equal(result.groups[2].lines.length, element.context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+              expectedCollapseSize);
+        });
+
+        test('first', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          const chunks = [
+            {ab: rows},
+            {a: ['foo']},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+          const expectedCollapseSize = rows.length - element.context;
+
+          assert.equal(result.groups.length, 2, 'Results in two groups');
+
+          // Only the first group is collapsed.
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.groups[1].lines.length, element.context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+              expectedCollapseSize);
+        });
+
+        test('few-rows', () => {
+          // Only ten rows.
+          rows = rows.slice(0, 10);
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          const chunks = [
+            {ab: rows},
+            {a: ['foo']},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
+        });
+
+        test('no single line collapse', () => {
+          rows = rows.slice(0, 7);
+          element.context = 3;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
+        });
+
+        suite('with key location', () => {
+          let state;
+          let chunks;
+
+          setup(() => {
+            state = {
+              lineNums: {left: 10, right: 100},
+            };
+            element.context = 10;
+            chunks = [
+              {ab: rows},
+              {ab: ['foo'], keyLocation: true},
+              {ab: rows},
+            ];
+          });
+
+          test('context before', () => {
+            state.chunkIndex = 0;
+            const result = element._processNext(state, chunks);
+
+            // The first chunk is split into two groups:
+            // 1) A context-control, hiding everything but the context before
+            //    the key location.
+            // 2) The context before the key location.
+            // The key location is not processed in this call to _processNext
+            assert.equal(result.groups.length, 2);
+            assert.equal(result.groups[0].lines.length, 1);
+            // The collapsed group has the hidden lines as its context group.
+            assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+                rows.length - element.context);
+            assert.equal(result.groups[1].lines.length, element.context);
+          });
+
+          test('key location itself', () => {
+            state.chunkIndex = 1;
+            const result = element._processNext(state, chunks);
+
+            // The second chunk results in a single group, that is just the
+            // line with the key location
+            assert.equal(result.groups.length, 1);
+            assert.equal(result.groups[0].lines.length, 1);
+            assert.equal(result.lineDelta.left, 1);
+            assert.equal(result.lineDelta.right, 1);
+          });
+
+          test('context after', () => {
+            state.chunkIndex = 2;
+            const result = element._processNext(state, chunks);
+
+            // The last chunk is split into two groups:
+            // 1) The context after the key location.
+            // 1) A context-control, hiding everything but the context after the
+            //    key location.
+            assert.equal(result.groups.length, 2);
+            assert.equal(result.groups[0].lines.length, element.context);
+            assert.equal(result.groups[1].lines.length, 1);
+            // The collapsed group has the hidden lines as its context group.
+            assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+                rows.length - element.context);
+          });
+        });
+      });
 
       suite('gr-diff-processor helpers', () => {
         let rows;
@@ -483,90 +841,10 @@
           rows = loremIpsum.split(' ');
         });
 
-        test('_sharedGroupsFromRows WHOLE_FILE', () => {
-          const context = WHOLE_FILE;
-          const lineNumbers = {left: 10, right: 100};
-          const result = element._sharedGroupsFromRows(
-              rows, context, lineNumbers.left, lineNumbers.right, null);
-
-          // Results in one, uncollapsed group with all rows.
-          assert.equal(result.length, 1);
-          assert.equal(result[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(result[0].lines.length, rows.length);
-
-          // Line numbers are set correctly.
-          assert.equal(result[0].lines[0].beforeNumber, lineNumbers.left + 1);
-          assert.equal(result[0].lines[0].afterNumber, lineNumbers.right + 1);
-
-          assert.equal(result[0].lines[rows.length - 1].beforeNumber,
-              lineNumbers.left + rows.length);
-          assert.equal(result[0].lines[rows.length - 1].afterNumber,
-              lineNumbers.right + rows.length);
-        });
-
-        test('_sharedGroupsFromRows context', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, null);
-          const expectedCollapseSize = rows.length - 2 * context;
-
-          assert.equal(result.length, 3, 'Results in three groups');
-
-          // The first and last are uncollapsed context, whereas the middle has
-          // a single context-control line.
-          assert.equal(result[0].lines.length, context);
-          assert.equal(result[1].lines.length, 1);
-          assert.equal(result[2].lines.length, context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[1].lines[0].contextGroup.lines.length,
-              expectedCollapseSize);
-        });
-
-        test('_sharedGroupsFromRows first', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
-          const expectedCollapseSize = rows.length - context;
-
-          assert.equal(result.length, 2, 'Results in two groups');
-
-          // Only the first group is collapsed.
-          assert.equal(result[0].lines.length, 1);
-          assert.equal(result[1].lines.length, context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[0].lines[0].contextGroup.lines.length,
-              expectedCollapseSize);
-        });
-
-        test('_sharedGroupsFromRows few-rows', () => {
-          // Only ten rows.
-          rows = rows.slice(0, 10);
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
-
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.length, 1, 'Results in one group');
-          assert.equal(result[0].lines.length, rows.length);
-        });
-
-        test('_sharedGroupsFromRows no single line collapse', () => {
-          rows = rows.slice(0, 7);
-          const context = 3;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100);
-
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.length, 1, 'Results in one group');
-          assert.equal(result[0].lines.length, rows.length);
-        });
-
-        test('_deltaLinesFromRows', () => {
+        test('_linesFromRows', () => {
           const startLineNum = 10;
-          let result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
-              startLineNum);
+          let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.ADD);
@@ -576,8 +854,8 @@
               startLineNum + rows.length);
           assert.notOk(result[result.length - 1].beforeNumber);
 
-          result = element._deltaLinesFromRows(GrDiffLine.Type.REMOVE, rows,
-              startLineNum);
+          result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
@@ -590,19 +868,19 @@
       });
 
       suite('_breakdown*', () => {
-        test('_breakdownGroup breaks down additions', () => {
+        test('_breakdownChunk breaks down additions', () => {
           sandbox.spy(element, '_breakdown');
           const chunk = {b: ['blah', 'blah', 'blah']};
-          const result = element._breakdownGroup(chunk);
+          const result = element._breakdownChunk(chunk);
           assert.deepEqual(result, [chunk]);
           assert.isTrue(element._breakdown.called);
         });
 
-        test('_breakdownGroup keeps due_to_rebase for broken down additions',
+        test('_breakdownChunk keeps due_to_rebase for broken down additions',
             () => {
               sandbox.spy(element, '_breakdown');
               const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-              const result = element._breakdownGroup(chunk);
+              const result = element._breakdownChunk(chunk);
               for (const subResult of result) {
                 assert.isTrue(subResult.due_to_rebase);
               }
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 f9822f2..cfa46a0 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
@@ -14,36 +14,13 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<script src="../../../scripts/util.js"></script>
 
 <dom-module id="gr-diff-selection">
   <template>
-    <style include="shared-styles">
-      .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 3484693..5caac74 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
@@ -32,7 +32,6 @@
 
   Polymer({
     is: 'gr-diff-selection',
-    _legacyUndefinedCheck: true,
 
     properties: {
       diff: Object,
@@ -73,7 +72,26 @@
       this._linesCache = getNewCache();
     },
 
+    _handleDownOnRangeComment(node) {
+      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; }
@@ -161,7 +179,18 @@
     },
 
     /**
-     * Get the text of the current window selection. If commentSelected is
+     * For Polymer 2, use shadowRoot.getSelection instead.
+     */
+    _getSelection() {
+      const diffHost = util.querySelector(document.body, 'gr-diff');
+      const selection = diffHost &&
+        diffHost.shadowRoot &&
+        diffHost.shadowRoot.getSelection();
+      return selection ? selection: window.getSelection();
+    },
+
+    /**
+     * Get the text of the current selection. If commentSelected is
      * true, it returns only the text of comments within the selection.
      * Otherwise it returns the text of the selected diff region.
      *
@@ -170,7 +199,7 @@
      * @return {string} The selected text.
      */
     _getSelectedText(side, commentSelected) {
-      const sel = window.getSelection();
+      const sel = this._getSelection();
       if (sel.rangeCount != 1) {
         return ''; // No multi-select support yet.
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index 469a894..0f5c6dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-selection</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff-selection.html">
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 5ebe738..17b8b4c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -15,12 +15,14 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
@@ -70,7 +72,7 @@
         justify-content: space-between;
       }
       header {
-        padding: .75em var(--default-horizontal-margin);
+        padding: var(--spacing-s) var(--spacing-l);
       }
       .patchRangeLeft {
         align-items: center;
@@ -85,11 +87,11 @@
         white-space: nowrap;
       }
       .navLink {
-        padding: 0 .25em;
+        padding: 0 var(--spacing-xs);
       }
       .reviewed {
         display: inline-block;
-        margin: 0 .25em;
+        margin: 0 var(--spacing-xs);
         vertical-align: .15em;
       }
       .jumpToFileContainer {
@@ -99,22 +101,19 @@
         display: none;
       }
       gr-button {
-        padding: .3em 0;
+        padding: var(--spacing-s) 0;
         text-decoration: none;
       }
       .loading {
         color: var(--deemphasized-text-color);
-        font-size: 2rem;
+        font-size: var(--font-size-h1);
         height: 100%;
-        padding: 1em var(--default-horizontal-margin);
+        padding: var(--spacing-l);
         text-align: center;
       }
       .subHeader {
         flex-wrap: wrap;
-        margin: 0 var(--default-horizontal-margin) .75em;
-      }
-      .subHeader > div {
-        margin-top: .25em;
+        padding: 0 var(--spacing-l) var(--spacing-s);
       }
       .prefsButton {
         text-align: right;
@@ -145,7 +144,7 @@
       }
       .diffModeSelector span,
       .editButton span {
-        margin-right: .2rem;
+        margin-right: var(--spacing-xs);
       }
       .diffModeSelector.hide,
       .separator.hide {
@@ -161,7 +160,7 @@
       }
       @media screen and (max-width: 50em) {
         header {
-          padding: .5em var(--default-horizontal-margin);
+          padding: var(--spacing-s) var(--spacing-l);
         }
         .dash {
           display: none;
@@ -172,23 +171,23 @@
         .fileNav {
           align-items: flex-start;
           display: flex;
-          margin: 0 .25em;
+          margin: 0 var(--spacing-xs);
         }
         .fullFileName {
           display: block;
           font-style: italic;
           min-width: 50%;
-          padding: 0 .1em;
+          padding: 0 var(--spacing-xxs);
           text-align: center;
           width: 100%;
           word-wrap: break-word;
         }
         .reviewed {
-          vertical-align: -.1em;
+          vertical-align: -1px;
         }
         .mobileNavLink {
           color: var(--primary-text-color);
-          font-size: 1.5rem;
+          font-size: var(--font-size-h2);
           font-weight: var(--font-weight-bold);
           text-decoration: none;
         }
@@ -287,7 +286,8 @@
             <gr-button
                 link
                 disabled="[[_isBlameLoading]]"
-                on-tap="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
+                on-click="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
+            <span class="separator"></span>
           </span>
           <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
             <span class="separator"></span>
@@ -314,13 +314,15 @@
                   class="prefsButton"
                   has-tooltip
                   title="Diff preferences"
-                  on-tap="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
+                  on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
             </span>
           </span>
           <gr-endpoint-decorator name="annotation-toggler">
             <span hidden id="annotation-span">
               <label for="annotation-checkbox" id="annotation-label"></label>
-              <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+              <iron-input type="checkbox" disabled>
+                <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+              </iron-input>
             </span>
           </gr-endpoint-decorator>
         </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index d73042e..bd0486f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -35,7 +35,6 @@
 
   Polymer({
     is: 'gr-diff-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -102,14 +101,24 @@
       // element for selected a file to view.
       _formattedFiles: {
         type: Array,
-        computed: '_formatFilesForDropdown(_fileList, _patchRange.patchNum, ' +
+        computed: '_formatFilesForDropdown(_files, _patchRange.patchNum, ' +
             '_changeComments)',
       },
       // An sorted array of files, as returned by the rest API.
       _fileList: {
         type: Array,
-        value() { return []; },
+        computed: '_getSortedFileList(_files)',
       },
+      /**
+       * Contains information about files as returned by the rest API.
+       *
+       * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
+       */
+      _files: {
+        type: Object,
+        value() { return {sortedFileList: [], changeFilesByPath: {}}; },
+      },
+
       _path: {
         type: String,
         observer: '_pathChanged',
@@ -181,6 +190,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.PathListBehavior,
@@ -189,7 +199,7 @@
 
     observers: [
       '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.*)',
+      '_getFiles(_changeNum, _patchRange.*, _changeComments)',
       '_setReviewedObserver(_loggedIn, params.*, _prefs)',
     ],
 
@@ -262,11 +272,31 @@
       return this.$.restAPI.getChangeEdit(this._changeNum);
     },
 
-    _getFiles(changeNum, patchRangeRecord) {
+    _getSortedFileList(files) {
+      return files.sortedFileList;
+    },
+
+    _getFiles(changeNum, patchRangeRecord, changeComments) {
+      // Polymer 2: check for undefined
+      if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
+          .some(arg => arg === undefined)) {
+        return Promise.resolve();
+      }
+
       const patchRange = patchRangeRecord.base;
-      return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
-          changeNum, patchRange).then(files => {
-        this._fileList = files;
+      return this.$.restAPI.getChangeFiles(
+          changeNum, patchRange).then(changeFiles => {
+        if (!changeFiles) return;
+        const commentedPaths = changeComments.getPaths(patchRange);
+        const files = Object.assign({}, changeFiles);
+        Object.keys(commentedPaths).forEach(commentedPath => {
+          if (files.hasOwnProperty(commentedPath)) { return; }
+          files[commentedPath] = {status: 'U'};
+        });
+        this._files = {
+          sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
+          changeFilesByPath: files,
+        };
       });
     },
 
@@ -562,7 +592,7 @@
      * @return {?Object}
      */
     _getNavLinkPath(path, fileList, direction, opt_noUp) {
-      if (!path || fileList.length === 0) { return null; }
+      if (!path || !fileList || fileList.length === 0) { return null; }
 
       let idx = fileList.indexOf(path);
       if (idx === -1) {
@@ -608,11 +638,11 @@
       this._initCursor(this.params);
 
       this._changeNum = value.changeNum;
+      this._path = value.path;
       this._patchRange = {
         patchNum: value.patchNum,
         basePatchNum: value.basePatchNum || PARENT,
       };
-      this._path = value.path;
 
       // NOTE: This may be called before attachment (e.g. while parentElement is
       // null). Fire title-change in an async so that, if attachment to the DOM
@@ -640,22 +670,24 @@
       promises.push(this._getChangeDetail(this._changeNum).then(change => {
         let commit;
         let baseCommit;
-        for (const commitSha in change.revisions) {
-          if (!change.revisions.hasOwnProperty(commitSha)) continue;
-          const revision = change.revisions[commitSha];
-          const patchNum = revision._number.toString();
-          if (patchNum === this._patchRange.patchNum) {
-            commit = commitSha;
-            const commitObj = revision.commit || {};
-            const parents = commitObj.parents || [];
-            if (this._patchRange.basePatchNum === PARENT && parents.length) {
-              baseCommit = parents[parents.length - 1].commit;
+        if (change) {
+          for (const commitSha in change.revisions) {
+            if (!change.revisions.hasOwnProperty(commitSha)) continue;
+            const revision = change.revisions[commitSha];
+            const patchNum = revision._number.toString();
+            if (patchNum === this._patchRange.patchNum) {
+              commit = commitSha;
+              const commitObj = revision.commit || {};
+              const parents = commitObj.parents || [];
+              if (this._patchRange.basePatchNum === PARENT && parents.length) {
+                baseCommit = parents[parents.length - 1].commit;
+              }
+            } else if (patchNum === this._patchRange.basePatchNum) {
+              baseCommit = commitSha;
             }
-          } else if (patchNum === this._patchRange.basePatchNum) {
-            baseCommit = commitSha;
           }
+          this._commitRange = {commit, baseCommit};
         }
-        this._commitRange = {commit, baseCommit};
       }));
 
       promises.push(this._loadComments());
@@ -674,8 +706,10 @@
         }
         this._loading = false;
         this.$.diffHost.comments = this._commentsForDiff;
-        return this.$.diffHost.reload();
+        return this.$.diffHost.reload(true);
       }).then(() => {
+        this.$.reporting.diffViewFullyLoaded();
+        // If diff view displayed has not ended yet, it ends here.
         this.$.reporting.diffViewDisplayed();
       });
     },
@@ -690,6 +724,11 @@
     },
 
     _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
+      // Polymer 2: check for undefined
+      if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
+        return;
+      }
+
       const params = paramsRecord.base || {};
       if (!_loggedIn) { return; }
 
@@ -741,6 +780,9 @@
     },
 
     _getDiffUrl(change, patchRange, path) {
+      if ([change, patchRange, path].some(arg => arg === undefined)) {
+        return '';
+      }
       return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
           patchRange.basePatchNum);
     },
@@ -779,6 +821,9 @@
     },
 
     _getChangePath(change, patchRange, revisions) {
+      if ([change, patchRange].some(arg => arg === undefined)) {
+        return '';
+      }
       const range = this._getChangeUrlRange(patchRange, revisions);
       return Gerrit.Nav.getUrlForChange(change, range.patchNum,
           range.basePatchNum);
@@ -793,22 +838,31 @@
       return this._getChangePath(change, patchRangeRecord.base, revisions);
     },
 
-    _formatFilesForDropdown(fileList, patchNum, changeComments) {
-      if (!fileList) { return; }
+    _formatFilesForDropdown(files, patchNum, changeComments) {
+      // Polymer 2: check for undefined
+      if ([
+        files,
+        patchNum,
+        changeComments,
+      ].some(arg => arg === undefined)) {
+        return;
+      }
+
+      if (!files) { return; }
       const dropdownContent = [];
-      for (const path of fileList) {
+      for (const path of files.sortedFileList) {
         dropdownContent.push({
           text: this.computeDisplayPath(path),
           mobileText: this.computeTruncatedPath(path),
           value: path,
           bottomText: this._computeCommentString(changeComments, patchNum,
-              path),
+              path, files.changeFilesByPath[path]),
         });
       }
       return dropdownContent;
     },
 
-    _computeCommentString(changeComments, patchNum, path) {
+    _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
       const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
           path);
       const commentCount = changeComments.computeCommentCount(patchNum, path);
@@ -817,11 +871,13 @@
       const unresolvedString = GrCountStringFormatter.computeString(
           unresolvedCount, 'unresolved');
 
-      return commentString +
-          // Add a space if both comments and unresolved
-          (commentString && unresolvedString ? ', ' : '') +
-          // Add parentheses around unresolved if it exists.
-          (unresolvedString ? `${unresolvedString}` : '');
+      const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
+
+      return [
+        unmodifiedString,
+        commentString,
+        unresolvedString]
+          .filter(v => v && v.length > 0).join(', ');
     },
 
     _computePrefsButtonHidden(prefs, prefsDisabled) {
@@ -990,6 +1046,15 @@
     },
 
     _computeCommentSkips(commentMap, fileList, path) {
+      // Polymer 2: check for undefined
+      if ([
+        commentMap,
+        fileList,
+        path,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const skips = {previous: null, next: null};
       if (!fileList.length) { return skips; }
       const pathIndex = fileList.indexOf(path);
@@ -1070,6 +1135,11 @@
     },
 
     _computeFileNum(file, files) {
+      // Polymer 2: check for undefined
+      if ([file, files].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return files.findIndex(({value}) => value === file) + 1;
     },
 
@@ -1109,13 +1179,12 @@
       this._getDiffPreferences();
     },
 
-    _computeIsLoggedIn(loggedIn) {
-      return loggedIn ? true : false;
-    },
-
     _computeCanEdit(loggedIn, changeChangeRecord) {
-      return this._computeIsLoggedIn(loggedIn) &&
-          this.changeIsOpen(changeChangeRecord.base.status);
+      if ([changeChangeRecord, changeChangeRecord.base]
+          .some(arg => arg === undefined)) {
+        return false;
+      }
+      return loggedIn && this.changeIsOpen(changeChangeRecord.base);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index b33c54c..9c59577 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-diff-view.html">
@@ -74,6 +76,17 @@
 
     const PARENT = 'PARENT';
 
+    function getFilesFromFileList(fileList) {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = {};
+        return files;
+      }, {});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
+
     setup(() => {
       sandbox = sinon.sandbox.create();
 
@@ -153,7 +166,8 @@
           a: {_number: 10, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
       element._loggedIn = true;
@@ -260,7 +274,8 @@
           b: {_number: 5, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
@@ -324,7 +339,8 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
@@ -517,13 +533,22 @@
         unresolvedCountStub.withArgs(3, path).returns(2);
         unresolvedCountStub.withArgs(4, path).returns(0);
 
-        assert.equal(element._computeCommentString(comments, 1, path),
+        assert.equal(element._computeCommentString(comments, 1, path, {}),
             '1 unresolved');
-        assert.equal(element._computeCommentString(comments, 2, path),
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'M'}),
             '1 comment');
-        assert.equal(element._computeCommentString(comments, 3, path),
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'U'}),
+            'no changes, 1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 3, path, {status: 'A'}),
             '2 comments, 2 unresolved');
-        assert.equal(element._computeCommentString(comments, 4, path), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'M'}), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'U'}),
+            'no changes');
         done();
       });
     });
@@ -545,8 +570,9 @@
           patchNum: '10',
         };
         element._change = {_number: 42};
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md',
-          '/COMMIT_MSG', '/MERGE_LIST'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md',
+              '/COMMIT_MSG', '/MERGE_LIST']);
         element._path = 'glados.txt';
         const expectedFormattedFiles = [
           {
@@ -595,7 +621,8 @@
             a: {_number: 10, commit: {parents: []}},
           },
         };
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
         const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
@@ -637,7 +664,8 @@
             b: {_number: 10, commit: {parents: []}},
           },
         };
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
         const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
@@ -1064,9 +1092,9 @@
         setup(() => {
           navToChangeStub = sandbox.stub(element, '_navToChangeView');
           navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-          element._fileList = [
+          element._files = getFilesFromFileList([
             'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ];
+          ]);
           element._patchRange = {patchNum: '2', basePatchNum: '1'};
         });
 
@@ -1217,7 +1245,7 @@
     });
 
     test('shift+m navigates to next unreviewed file', () => {
-      element._fileList = ['file1', 'file2', 'file3'];
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       element._reviewedFiles = new Set(['file1', 'file2']);
       element._path = 'file1';
       const reviewedStub = sandbox.stub(element, '_setReviewed');
@@ -1233,6 +1261,39 @@
       ]);
     });
 
+    test('File change should trigger navigateToDiff once', () => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sandbox.stub(element, '_getLineOfInterest');
+      sandbox.stub(element, '_initCursor');
+      sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+
+      // Load file1
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file1',
+      });
+      assert.isTrue(Gerrit.Nav.navigateToDiff.notCalled);
+
+      // Switch to file2
+      element.$.dropdown.value = 'file2';
+      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+
+      // This is to mock the param change triggered by above navigate
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file2',
+      });
+
+      // No extra call
+      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+    });
+
     test('_computeDownloadDropdownLinks', () => {
       const downloadLinks = [
         {
@@ -1328,4 +1389,54 @@
           '/changes/test~12/revisions/1/patch?zip&path=index.php');
     });
   });
+
+  suite('gr-diff-view tests unmodified files with comments', () => {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      const changedFiles = {
+        'file1.txt': {},
+        'a/b/test.c': {},
+      };
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve(changedFiles); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = fixture('basic');
+      return element._loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_getFiles add files with comments without changes', () => {
+      const patchChangeRecord = {
+        base: {
+          basePatchNum: '5',
+          patchNum: '10',
+        },
+      };
+      const changeComments = {
+        getPaths: sandbox.stub().returns({'file2.txt': {}, 'file1.txt': {}}),
+      };
+      return element._getFiles(23, patchChangeRecord, changeComments).then(() => {
+        assert.deepEqual(element._files, {
+          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+          changeFilesByPath: {
+            'file1.txt': {},
+            'file2.txt': {status: 'U'},
+            'a/b/test.c': {},
+          },
+        });
+      });
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index dd69724..a7e391a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -22,20 +22,42 @@
 
   /**
    * A chunk of the diff that should be rendered together.
+   *
+   * @param {!GrDiffGroup.Type} type
+   * @param {!Array<!GrDiffLine>=} opt_lines
    */
   function GrDiffGroup(type, opt_lines) {
+    /** @type {!GrDiffGroup.Type} */
     this.type = type;
 
-    /** @type{!Array<!GrDiffLine>} */
+    /** @type {boolean} */
+    this.dueToRebase = false;
+
+    /**
+     * True means all changes in this line are whitespace changes that should
+     * not be highlighted as changed as per the user settings.
+     *
+     * @type{boolean}
+     */
+    this.ignoredWhitespaceOnly = false;
+
+    /**
+     * True means it should not be collapsed (because it was in the URL, or
+     * there is a comment on that line)
+     */
+    this.keyLocation = false;
+
+    /** @type {?HTMLElement} */
+    this.element = null;
+
+    /** @type {!Array<!GrDiffLine>} */
     this.lines = [];
-    /** @type{!Array<!GrDiffLine>} */
+    /** @type {!Array<!GrDiffLine>} */
     this.adds = [];
-    /** @type{!Array<!GrDiffLine>} */
+    /** @type {!Array<!GrDiffLine>} */
     this.removes = [];
 
-    /** @type{boolean|undefined} */
-    this.dueToRebase = undefined;
-
+    /** Both start and end line are inclusive. */
     this.lineRange = {
       left: {start: null, end: null},
       right: {start: null, end: null},
@@ -46,8 +68,7 @@
     }
   }
 
-  GrDiffGroup.prototype.element = null;
-
+  /** @enum {string} */
   GrDiffGroup.Type = {
     /** Unchanged context. */
     BOTH: 'both',
@@ -59,6 +80,139 @@
     DELTA: 'delta',
   };
 
+
+  /**
+   * Hides lines in the given range behind a context control group.
+   *
+   * Groups that would be partially visible are split into their visible and
+   * hidden parts, respectively.
+   * The groups need to be "common groups", meaning they have to have either
+   * originated from an `ab` chunk, or from an `a`+`b` chunk with
+   * `common: true`.
+   *
+   * If the hidden range is 1 line or less, nothing is hidden and no context
+   * control group is created.
+   *
+   * @param {!Array<!GrDiffGroup>} groups Common groups, ordered by their line
+   *     ranges.
+   * @param {number} hiddenStart The first element to be hidden, as a
+   *     non-negative line number offset relative to the first group's start
+   *     line, left and right respectively.
+   * @param {number} hiddenEnd The first visible element after the hidden range,
+   *     as a non-negative line number offset relative to the first group's
+   *     start line, left and right respectively.
+   * @return {!Array<!GrDiffGroup>}
+   */
+  GrDiffGroup.hideInContextControl = function(groups, hiddenStart, hiddenEnd) {
+    if (groups.length === 0) return [];
+    // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+    hiddenStart = Math.max(hiddenStart, 0);
+    hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+    let before = [];
+    let hidden = groups;
+    let after = [];
+
+    const numHidden = hiddenEnd - hiddenStart;
+
+    // Only collapse if there is more than 1 line to be hidden.
+    if (numHidden > 1) {
+      if (hiddenStart) {
+        [before, hidden] = GrDiffGroup._splitCommonGroups(hidden, hiddenStart);
+      }
+      if (hiddenEnd) {
+        [hidden, after] = GrDiffGroup._splitCommonGroups(
+            hidden, hiddenEnd - hiddenStart);
+      }
+    } else {
+      [hidden, after] = [[], hidden];
+    }
+
+    const result = [...before];
+    if (hidden.length) {
+      const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+      ctxLine.contextGroups = hidden;
+      const ctxGroup = new GrDiffGroup(
+          GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
+      result.push(ctxGroup);
+    }
+    result.push(...after);
+    return result;
+  };
+
+  /**
+   * Splits a list of common groups into two lists of groups.
+   *
+   * Groups where all lines are before or all lines are after the split will be
+   * retained as is and put into the first or second list respectively. Groups
+   * with some lines before and some lines after the split will be split into
+   * two groups, which will be put into the first and second list.
+   *
+   * @param {!Array<!GrDiffGroup>} groups
+   * @param {number} split A line number offset relative to the first group's
+   *     start line at which the groups should be split.
+   * @return {!Array<!Array<!GrDiffGroup>>} The outer array has 2 elements, the
+   *   list of groups before and the list of groups after the split.
+   */
+  GrDiffGroup._splitCommonGroups = function(groups, split) {
+    if (groups.length === 0) return [[], []];
+    const leftSplit = groups[0].lineRange.left.start + split;
+    const rightSplit = groups[0].lineRange.right.start + split;
+
+    const beforeGroups = [];
+    const afterGroups = [];
+    for (const group of groups) {
+      if (group.lineRange.left.end < leftSplit ||
+          group.lineRange.right.end < rightSplit) {
+        beforeGroups.push(group);
+        continue;
+      }
+      if (leftSplit <= group.lineRange.left.start ||
+          rightSplit <= group.lineRange.right.start) {
+        afterGroups.push(group);
+        continue;
+      }
+
+      const before = [];
+      const after = [];
+      for (const line of group.lines) {
+        if ((line.beforeNumber && line.beforeNumber < leftSplit) ||
+            (line.afterNumber && line.afterNumber < rightSplit)) {
+          before.push(line);
+        } else {
+          after.push(line);
+        }
+      }
+
+      if (before.length) {
+        beforeGroups.push(before.length === group.lines.length ?
+          group : group.cloneWithLines(before));
+      }
+      if (after.length) {
+        afterGroups.push(after.length === group.lines.length ?
+          group : group.cloneWithLines(after));
+      }
+    }
+    return [beforeGroups, afterGroups];
+  };
+
+  /**
+   * Creates a new group with the same properties but different lines.
+   *
+   * The element property is not copied, because the original element is still a
+   * rendering of the old lines, so that would not make sense.
+   *
+   * @param {!Array<!GrDiffLine>} lines
+   * @return {!GrDiffGroup}
+   */
+  GrDiffGroup.prototype.cloneWithLines = function(lines) {
+    const group = new GrDiffGroup(this.type, lines);
+    group.dueToRebase = this.dueToRebase;
+    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+    return group;
+  };
+
+  /** @param {!GrDiffLine} line */
   GrDiffGroup.prototype.addLine = function(line) {
     this.lines.push(line);
 
@@ -77,6 +231,7 @@
     this._updateRange(line);
   };
 
+  /** @return {!Array<{left: GrDiffLine, right: GrDiffLine}>} */
   GrDiffGroup.prototype.getSideBySidePairs = function() {
     if (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 9dc5311..16e8036 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -18,8 +18,10 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-group</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="gr-diff-line.js"></script>
 <script src="gr-diff-group.js"></script>
@@ -28,12 +30,9 @@
   suite('gr-diff-group tests', () => {
     test('delta line pairs', () => {
       let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l2 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      l1.afterNumber = 128;
-      l2.afterNumber = 129;
-      l3.beforeNumber = 64;
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
+      const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
+      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
       group.addLine(l1);
       group.addLine(l2);
       group.addLine(l3);
@@ -64,17 +63,9 @@
     });
 
     test('group/header line pairs', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l1.beforeNumber = 64;
-      l1.afterNumber = 128;
-
-      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l2.beforeNumber = 65;
-      l2.afterNumber = 129;
-
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l3.beforeNumber = 66;
-      l3.afterNumber = 130;
+      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
+      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
 
       let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
 
@@ -122,6 +113,97 @@
       assert.throws(group.addLine.bind(group, l2));
       assert.doesNotThrow(group.addLine.bind(group, l3));
     });
+
+    suite('hideInContextControl', () => {
+      let groups;
+      setup(() => {
+        groups = [
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+            new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+          ]),
+          new GrDiffGroup(GrDiffGroup.Type.DELTA, [
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+          ]),
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+            new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+          ]),
+        ];
+      });
+
+      test('hides hidden groups in context control', () => {
+        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+        assert.equal(collapsedGroups.length, 3);
+
+        assert.equal(collapsedGroups[0], groups[0]);
+
+        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+        assert.equal(collapsedGroups[1].lines.length, 1);
+        assert.equal(
+            collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+        assert.equal(
+            collapsedGroups[1].lines[0].contextGroups.length, 1);
+        assert.equal(
+            collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
+
+        assert.equal(collapsedGroups[2], groups[2]);
+      });
+
+      test('splits partially hidden groups', () => {
+        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+        assert.equal(collapsedGroups.length, 4);
+        assert.equal(collapsedGroups[0], groups[0]);
+
+        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+        assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+        assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+        assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+        assert.equal(collapsedGroups[2].lines.length, 1);
+        assert.equal(
+            collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups.length, 2);
+
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups[0].type,
+            GrDiffGroup.Type.DELTA);
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[0].adds,
+            groups[1].adds.slice(1));
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[0].removes,
+            groups[1].removes.slice(1));
+
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups[1].type,
+            GrDiffGroup.Type.BOTH);
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[1].lines,
+            [groups[2].lines[0]]);
+
+        assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+        assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+      });
+
+      test('groups unchanged if the hidden range is empty', () => {
+        assert.deepEqual(
+            GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+      });
+
+      test('groups unchanged if there is only 1 line to hide', () => {
+        assert.deepEqual(
+            GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+      });
+    });
   });
 
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 44bb52a..a295293 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -20,21 +20,32 @@
   // Prevent redefinition.
   if (window.GrDiffLine) { return; }
 
-  function GrDiffLine(type) {
+  /**
+   * @param {GrDiffLine.Type} type
+   * @param {number|string=} opt_beforeLine
+   * @param {number|string=} opt_afterLine
+   */
+  function GrDiffLine(type, opt_beforeLine, opt_afterLine) {
     this.type = type;
+
+    /** @type {number|string} */
+    this.beforeNumber = opt_beforeLine || 0;
+
+    /** @type {number|string} */
+    this.afterNumber = opt_afterLine || 0;
+
+    /** @type {boolean} */
+    this.hasIntralineInfo = false;
+
+    /** @type {Array<GrDiffLine.Highlights>} */
     this.highlights = [];
+
+    /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
+    this.contextGroups = null;
+
+    this.text = '';
   }
 
-  /** @type {number|string} */
-  GrDiffLine.prototype.afterNumber = 0;
-
-  /** @type {number|string} */
-  GrDiffLine.prototype.beforeNumber = 0;
-
-  GrDiffLine.prototype.contextGroup = null;
-
-  GrDiffLine.prototype.text = '';
-
   GrDiffLine.Type = {
     ADD: 'add',
     BOTH: 'both',
@@ -43,6 +54,23 @@
     REMOVE: 'remove',
   };
 
+  /**
+   * A line highlight object consists of three fields:
+   * - contentIndex: The index of the chunk `content` field (the line
+   *   being referred to).
+   * - startIndex: Index of the character where the highlight should begin.
+   * - endIndex: (optional) Index of the character where the highlight should
+   *   end. If omitted, the highlight is meant to be a continuation onto the
+   *   next line.
+   *
+   * @typedef {{
+   *  contentIndex: number,
+   *  startIndex: number,
+   *  endIndex: number
+   * }}
+   */
+  GrDiffLine.Highlights;
+
   GrDiffLine.FILE = 'FILE';
 
   GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
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 72fc1ee..1c36745 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -23,19 +24,31 @@
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
+<link rel="import" href="../gr-ranged-comment-themes/gr-ranged-comment-theme.html">
 
 <script src="../../../scripts/hiddenscroll.js"></script>
 
 <dom-module id="gr-diff">
   <template>
     <style include="shared-styles">
-      :host(.no-left) .sideBySide ::content .left,
-      :host(.no-left) .sideBySide ::content .left + td,
-      :host(.no-left) .sideBySide ::content .right:not([data-value]),
-      :host(.no-left) .sideBySide ::content .right:not([data-value]) + td {
+      :host(.no-left) .sideBySide .left,
+      :host(.no-left) .sideBySide .left + td,
+      :host(.no-left) .sideBySide .right:not([data-value]),
+      :host(.no-left) .sideBySide .right:not([data-value]) + td {
         display: none;
       }
-      .thread-group, ::slotted(*) .thread-group {
+      ::slotted(*) .thread-group {
+        display: block;
+        max-width: var(--content-width, 80ch);
+        white-space: normal;
+      }
+      :host {
+        font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+        font-size: var(--font-size, var(--font-size-code, 12px));
+        line-height: var(--line-height-code, 1.334);
+      }
+
+      .thread-group {
         display: block;
         max-width: var(--content-width, 80ch);
         white-space: normal;
@@ -46,7 +59,7 @@
         @apply --diff-container-styles;
       }
       .diffContainer.hiddenscroll {
-        margin-bottom: .8em;
+        margin-bottom: var(--spacing-m);
       }
       table {
         border-collapse: collapse;
@@ -80,10 +93,12 @@
         background-color: var(--diff-selection-background-color);
         color: var(--primary-text-color);
       }
-      .blank,
       .content {
         background-color: var(--view-background-color);
       }
+      .blank {
+        background-color: var(--diff-blank-background-color);
+      }
       .image-diff .content {
         background-color: var(--table-header-background-color);
       }
@@ -96,8 +111,6 @@
       }
       .lineNum,
       .content {
-        /* Set font size based the user's diff preference. */
-        font-size: var(--font-size, var(--font-size-normal));
         vertical-align: top;
         white-space: pre;
       }
@@ -109,7 +122,7 @@
         user-select: none;
 
         color: var(--deemphasized-text-color);
-        padding: 0 .5em;
+        padding: 0 var(--spacing-m);
         text-align: right;
       }
       .canComment .lineNum {
@@ -123,6 +136,8 @@
         width: var(--content-width, 80ch);
       }
       .content.add .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.add.no-intraline-info,
       .delta.total .content.add {
         background-color: var(--dark-add-highlight-color);
       }
@@ -130,12 +145,16 @@
         background-color: var(--light-add-highlight-color);
       }
       .content.remove .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.remove.no-intraline-info,
       .delta.total .content.remove {
         background-color: var(--dark-remove-highlight-color);
       }
       .content.remove {
         background-color: var(--light-remove-highlight-color);
       }
+
+      /* dueToRebase */
       .dueToRebase .content.add .intraline,
       .delta.total.dueToRebase .content.add {
         background-color: var(--dark-rebased-add-highlight-color);
@@ -150,20 +169,32 @@
       .dueToRebase .content.remove {
         background-color: var(--light-remove-add-highlight-color);
       }
+
+      /* ignoredWhitespaceOnly */
+      .ignoredWhitespaceOnly .content.add .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.add,
+      .ignoredWhitespaceOnly .content.add,
+      .ignoredWhitespaceOnly .content.remove .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.remove,
+      .ignoredWhitespaceOnly .content.remove {
+        background: none;
+      }
+
       .content .contentText:empty:after {
         /* Newline, to ensure empty lines are one line-height tall. */
         content: '\A';
       }
       .contextControl {
-        background-color: var(--diff-context-control-color);
+        background-color: var(--diff-context-control-background-color);
         border: 1px solid var(--diff-context-control-border-color);
+        color: var(--diff-context-control-color);
       }
       .contextControl gr-button {
         display: inline-block;
         text-decoration: none;
         --gr-button: {
-          color: var(--deemphasized-text-color);
-          padding: .2em;
+          color: var(--diff-context-control-color);
+          padding: var(--spacing-xs);
         }
       }
       .contextControl td:not(.lineNum) {
@@ -191,21 +222,19 @@
       .content .trailing-whitespace,
       .trailing-whitespace .intraline,
       .content .trailing-whitespace .intraline {
-        border-radius: .4em;
+        border-radius: var(--border-radius, 4px);
         background-color: var(--diff-trailing-whitespace-indicator);
       }
       #diffHeader {
         background-color: var(--table-header-background-color);
         border-bottom: 1px solid var(--border-color);
         color: var(--link-color);
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size, var(--font-size-normal));
-        padding: 0.5em 0 0.5em 4em;
+        padding: var(--spacing-m) 0 var(--spacing-m) 48px;
       }
       #loadingError,
       #sizeWarning {
         display: none;
-        margin: 1em auto;
+        margin: var(--spacing-l) auto;
         max-width: 60em;
         text-align: center;
       }
@@ -213,7 +242,7 @@
         color: var(--error-text-color);
       }
       #sizeWarning gr-button {
-        margin: 1em;
+        margin: var(--spacing-l);
       }
       #loadingError.showError,
       #sizeWarning.warn {
@@ -227,9 +256,7 @@
       }
       td.blame {
         display: none;
-        font-family: var(--font-family);
-        font-size: var(--font-size, var(--font-size-normal));
-        padding: 0 .5em;
+        padding: 0 var(--spacing-m);
         white-space: pre;
       }
       :host(.showBlame) col.blame {
@@ -251,11 +278,6 @@
         overflow: hidden;
         width: 200px;
       }
-      /** Since the line limit position is determined by charachter size, blank
-       lines also need to have the same font size as everything else */
-      .full-width .blank {
-        font-size: var(--font-size, var(--font-size-normal));
-      }
       /** Support the line length indicator **/
       .full-width td.content,
       .full-width td.blank {
@@ -280,8 +302,50 @@
       .lineNum.PARTIALLY_COVERED {
         background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
       }
+
+      /** BEGIN: Select and copy for Polymer 2 */
+      /** Below was copied and modified from the original css in gr-diff-selection.html */
+      .content,
+      .contextControl,
+      .blame {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+      }
+
+      .selected-left:not(.selected-comment) .side-by-side .left + .content .contentText,
+      .selected-right:not(.selected-comment) .side-by-side .right + .content .contentText,
+      .selected-left:not(.selected-comment) .unified .left.lineNum ~ .content:not(.both) .contentText,
+      .selected-right:not(.selected-comment) .unified .right.lineNum ~ .content .contentText,
+      .selected-left.selected-comment .side-by-side .left + .content .message,
+      .selected-right.selected-comment .side-by-side .right + .content .message :not(.collapsedContent),
+      .selected-comment .unified .message :not(.collapsedContent),
+      .selected-blame .blame {
+        -webkit-user-select: text;
+        -moz-user-select: text;
+        -ms-user-select: text;
+        user-select: text;
+      }
+
+      /** Make comments selectable when selected */
+      .selected-left.selected-comment ::slotted(gr-comment-thread[comment-side=left]),
+      .selected-right.selected-comment ::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);
+        text-align: center;
+      }
     </style>
     <style include="gr-syntax-theme"></style>
+    <style include="gr-ranged-comment-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
       <template
           is="dom-repeat"
@@ -302,18 +366,26 @@
               coverage-ranges="[[coverageRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
-              diff-path="[[path]]"
+              path="[[path]]"
               change-num="[[changeNum]]"
               patch-num="[[patchRange.patchNum]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[baseImage]]"
+              layers="[[layers]]"
               revision-image="[[revisionImage]]">
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
                 role="presentation"></table>
+
+            <template is="dom-if" if="[[showNoChangeMessage(loading, prefs, _diffLength)]]">
+              <div class="whitespace-change-only-message">
+                This file only contains whitespace changes.
+                Modify the whitespace setting to see the changes.
+              </div>
+            </template>
           </gr-diff-builder>
         </gr-diff-highlight>
       </gr-diff-selection>
@@ -329,10 +401,10 @@
         Prevented render because "Whole file" is enabled and this diff is very
         large (about [[_diffLength]] lines).
       </p>
-      <gr-button on-tap="_handleLimitedBypass">
+      <gr-button on-click="_handleLimitedBypass">
         Render with limited context
       </gr-button>
-      <gr-button on-tap="_handleFullBypass">
+      <gr-button on-click="_handleFullBypass">
         Render anyway (may be slow)
       </gr-button>
     </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 4e023d4..4f07664 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -35,26 +35,10 @@
     RIGHT: 'right',
   };
 
-  const Defs = {};
-
-  /**
-   * Special line number which should not be collapsed into a shared region.
-   *
-   * @typedef {{
-   *  number: number,
-   *  leftSide: boolean
-   * }}
-   */
-  Defs.LineOfInterest;
-
   const LARGE_DIFF_THRESHOLD_LINES = 10000;
   const FULL_CONTEXT = -1;
   const LIMITED_CONTEXT = 10;
 
-  /** @typedef {{start_line: number, start_character: number,
-   *             end_line: number, end_character: number}} */
-  Gerrit.Range;
-
   /**
    * Compare two ranges. Either argument may be falsy, but will only return
    * true if both are falsy or if neither are falsy and have the same position
@@ -86,6 +70,9 @@
    * implements the same behavior as the template parsing for imperative slots.
    */
   Gerrit.slotToContent = function(slot) {
+    if (Polymer.Element) {
+      return slot;
+    }
     const content = document.createElement('content');
     content.name = slot.name;
     content.setAttribute('select', `[slot='${slot.name}']`);
@@ -106,7 +93,6 @@
 
   Polymer({
     is: 'gr-diff',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user selects a line.
@@ -184,7 +170,7 @@
         observer: '_viewModeObserver',
       },
 
-      /** @type {?Defs.LineOfInterest} */
+      /** @type {?Gerrit.LineOfInterest} */
       lineOfInterest: Object,
 
       loading: {
@@ -271,9 +257,11 @@
 
       /** Set by Polymer. */
       isAttached: Boolean,
+      layers: Array,
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PatchSetBehavior,
     ],
 
@@ -295,7 +283,18 @@
       this._unobserveNodes();
     },
 
+    showNoChangeMessage(loading, prefs, diffLength) {
+      return !loading &&
+        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE'
+        && diffLength === 0;
+    },
+
     _enableSelectionObserver(loggedIn, isAttached) {
+      // Polymer 2: check for undefined
+      if ([loggedIn, isAttached].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (loggedIn && isAttached) {
         this.listen(document, 'selectionchange', '_handleSelectionChange');
         this.listen(document, 'mouseup', '_handleMouseUp');
@@ -361,7 +360,10 @@
         });
         this.splice('_commentRanges', i, 1);
       }
-      this.push('_commentRanges', ...addedCommentRanges);
+
+      if (addedCommentRanges && addedCommentRanges.length) {
+        this.push('_commentRanges', ...addedCommentRanges);
+      }
     },
 
     /**
@@ -384,7 +386,13 @@
         const commentSide = threadEl.getAttribute('comment-side');
         const lineNum = Number(threadEl.getAttribute('line-num')) ||
             GrDiffLine.FILE;
+        const commentRange = threadEl.range || {};
         keyLocations[commentSide][lineNum] = true;
+        // Add start_line as well if exists,
+        // the being and end of the range should not be collapsed.
+        if (commentRange.start_line) {
+          keyLocations[commentSide][commentRange.start_line] = true;
+        }
       }
       return keyLocations;
     },
@@ -393,12 +401,12 @@
     _redispatchHoverEvents(addedThreadEls) {
       for (const threadEl of addedThreadEls) {
         threadEl.addEventListener('mouseenter', () => {
-          threadEl.dispatchEvent(
-              new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseenter', {bubbles: true, composed: true}));
         });
         threadEl.addEventListener('mouseleave', () => {
-          threadEl.dispatchEvent(
-              new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseleave', {bubbles: true, composed: true}));
         });
       }
     },
@@ -415,7 +423,6 @@
         return [];
       }
 
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('.diff-row'));
     },
@@ -554,6 +561,7 @@
           this._getIsParentCommentByLineAndContent(lineEl, contentEl);
       this.dispatchEvent(new CustomEvent('create-comment', {
         bubbles: true,
+        composed: true,
         detail: {
           lineNum,
           side,
@@ -713,7 +721,7 @@
 
     _diffChanged(newValue) {
       if (newValue) {
-        this._diffLength = this.$.diffBuilder.getDiffLength();
+        this._diffLength = this.getDiffLength(newValue);
         this._debounceRenderDiffTable();
       }
     },
@@ -736,14 +744,16 @@
     _renderDiffTable() {
       this._unobserveIncrementalNodes();
       if (!this.prefs) {
-        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
         return;
       }
       if (this.prefs.context === -1 &&
           this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
           this._safetyBypass === null) {
         this._showWarning = true;
-        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
         return;
       }
 
@@ -753,7 +763,11 @@
       this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
           .then(() => {
             this.dispatchEvent(
-                new CustomEvent('render', {bubbles: true}));
+                new CustomEvent('render', {
+                  bubbles: true,
+                  composed: true,
+                  detail: {contentRendered: true},
+                }));
           });
     },
 
@@ -765,6 +779,7 @@
         // not hurt. It's probably a bigger performance cost to remove them than
         // to keep them around. Medium term we can even consider to add one slot
         // for each line from the start.
+        let lastEl;
         for (const threadEl of addedThreadEls) {
           const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
           const commentSide = threadEl.getAttribute('comment-side');
@@ -783,6 +798,14 @@
           const slot = document.createElement('slot');
           slot.name = threadEl.getAttribute('slot');
           Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
+          lastEl = threadEl;
+        }
+
+        // Safari is not binding newly created comment-thread
+        // with the slot somehow, replace itself will rebind it
+        // @see Issue 11182
+        if (lastEl && lastEl.replaceWith) {
+          lastEl.replaceWith(lastEl);
         }
       });
     },
@@ -886,7 +909,8 @@
           !chunk.ab &&
 
           // The chunk doesn't have the given side.
-          ((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
+          ((leftSide && (!chunk.a || !chunk.a.length)) ||
+           (!leftSide && (!chunk.b || !chunk.b.length))));
 
       // If we reached the beginning of the diff and failed to find a chunk
       // with the given side, return null.
@@ -944,5 +968,25 @@
       if (loading || !warning) { return 'newlineWarning hidden'; }
       return 'newlineWarning';
     },
+
+    /**
+     * Get the approximate length of the diff as the sum of the maximum
+     * length of the chunks.
+     *
+     * @param {Object} diff object
+     * @return {number}
+     */
+    getDiffLength(diff) {
+      if (!diff) return 0;
+      return diff.content.reduce((sum, sec) => {
+        if (sec.hasOwnProperty('ab')) {
+          return sum + sec.ab.length;
+        } else {
+          return sum + Math.max(
+              sec.hasOwnProperty('a') ? sec.a.length : 0,
+              sec.hasOwnProperty('b') ? sec.b.length : 0);
+        }
+      }, 0);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 594d60ed..b177bad 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -18,12 +18,15 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff.html">
 
@@ -85,14 +88,14 @@
       element = fixture('basic');
       element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
       flushAsynchronousOperations();
-      assert.equal(element.customStyle['--line-limit'], '80ch');
+      assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
     });
 
     test('line limit without line_wrapping', () => {
       element = fixture('basic');
       element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
       flushAsynchronousOperations();
-      assert.isNotOk(element.customStyle['--line-limit']);
+      assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
     });
 
     suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
@@ -776,12 +779,12 @@
         element = fixture('basic');
         renderStub = sandbox.stub(element.$.diffBuilder, 'render',
             () => {
-              Promise.resolve();
               element.$.diffBuilder.dispatchEvent(
-                  new CustomEvent('render', {bubbles: true}));
+                  new CustomEvent('render', {bubbles: true, composed: true}));
+              return Promise.resolve({});
             });
         const mock = document.createElement('mock-diff-response');
-        sandbox.stub(element.$.diffBuilder, 'getDiffLength').returns(10000);
+        sandbox.stub(element, 'getDiffLength').returns(10000);
         element.diff = mock.diffResponse;
         element.noRenderOnPrefsChange = true;
       });
@@ -865,7 +868,7 @@
           assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
         });
 
-        test('addition', () => {
+        test('addition with a undefined', () => {
           const diff = {content: [
             {b: ['foo', 'bar', 'baz']},
           ]};
@@ -873,7 +876,16 @@
           assert.isNull(element._lastChunkForSide(diff, true));
         });
 
-        test('deletion', () => {
+        test('addition with a empty', () => {
+          const diff = {content: [
+            {a: [], b: ['foo', 'bar', 'baz']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+
+
+        test('deletion with b undefined', () => {
           const diff = {content: [
             {a: ['foo', 'bar', 'baz']},
           ]};
@@ -881,6 +893,14 @@
           assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
         });
 
+        test('deletion with b empty', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar', 'baz'], b: []},
+          ]};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+        });
+
         test('empty', () => {
           const diff = {content: []};
           assert.isNull(element._lastChunkForSide(diff, false));
@@ -1039,6 +1059,117 @@
         });
       });
     });
+
+    suite('whitespace changes only message', () => {
+      const setupDiff = function(ignore_whitespace, diffContent) {
+        element = fixture('basic');
+        element.prefs = {
+          ignore_whitespace,
+          auto_hide_diff_table_header: true,
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          intraline_difference: true,
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+          theme: 'DEFAULT',
+        };
+
+        element.diff = {
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.js b/carrot.js',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.js',
+            '+++ b/carrot.jjs',
+            'file differ',
+          ],
+          content: diffContent,
+          binary: true,
+        };
+
+        element._renderDiffTable();
+        flushAsynchronousOperations();
+      };
+
+      test('show the message if ignore_whitespace is criteria matches', () => {
+        setupDiff('IGNORE_ALL', [{skip: 100}]);
+        assert.isTrue(element.showNoChangeMessage(
+            /* loading= */ false,
+            element.prefs,
+            element._diffLength
+        ));
+      });
+
+      test('do not show the message if still loading', () => {
+        setupDiff('IGNORE_ALL', [{skip: 100}]);
+        assert.isFalse(element.showNoChangeMessage(
+            /* loading= */ true,
+            element.prefs,
+            element._diffLength
+        ));
+      });
+
+      test('do not show the message if contains valid changes', () => {
+        const content = [{
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        }, {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        }];
+        setupDiff('IGNORE_ALL', content);
+        assert.equal(element._diffLength, 3);
+        assert.isFalse(element.showNoChangeMessage(
+            /* loading= */ false,
+            element.prefs,
+            element._diffLength
+        ));
+      });
+
+      test('do not show message if ignore whitespace is disabled', () => {
+        const content = [{
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        }, {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        }];
+        setupDiff('IGNORE_NONE', content);
+        assert.isFalse(element.showNoChangeMessage(
+            /* loading= */ false,
+            element.prefs,
+            element._diffLength
+        ));
+      });
+    });
+
+    test('getDiffLength', () => {
+      const diff = document.createElement('mock-diff-response').diffResponse;
+      assert.equal(element.getDiffLength(diff), 52);
+    });
+
+    test('`render` event has contentRendered field in detail', done => {
+      element = fixture('basic');
+      element.prefs = {};
+      renderStub = sandbox.stub(element.$.diffBuilder, 'render')
+          .returns(Promise.resolve());
+      element.addEventListener('render', event => {
+        assert.isTrue(event.detail.contentRendered);
+        done();
+      });
+      element._renderDiffTable();
+    });
   });
 
   a11ySuite('basic');
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 3de4284..ee1f536 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -34,7 +34,7 @@
       }
       .arrow {
         color: var(--deemphasized-text-color);
-        margin: 0 .5em;
+        margin: 0 var(--spacing-m);
       }
       gr-dropdown-list {
         --trigger-style: {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index f913080..c7bf4ec3 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -31,7 +31,6 @@
 
   Polymer({
     is: 'gr-patch-range-select',
-    _legacyUndefinedCheck: true,
 
     properties: {
       availablePatches: Array,
@@ -68,6 +67,17 @@
 
     _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
         changeComments, revisionInfo) {
+      // Polymer 2: check for undefined
+      if ([
+        availablePatches,
+        patchNum,
+        _sortedRevisions,
+        changeComments,
+        revisionInfo,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const parentCounts = revisionInfo.getParentCountMap();
       const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
         parentCounts[patchNum] : 1;
@@ -111,6 +121,16 @@
 
     _computePatchDropdownContent(availablePatches, basePatchNum,
         _sortedRevisions, changeComments) {
+      // Polymer 2: check for undefined
+      if ([
+        availablePatches,
+        basePatchNum,
+        _sortedRevisions,
+        changeComments,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       const dropdownContent = [];
       for (const patch of availablePatches) {
         const patchNum = patch.num;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index f2b35a5..ee893ee 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-patch-range-select</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
index c9e9f50..17a4866 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <dom-module id="gr-ranged-comment-layer">
   <template>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 2183e7d..5a03579 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -17,17 +17,14 @@
 (function() {
   'use strict';
 
-  const HOVER_PATH_PATTERN = /^commentRanges\.\#(\d+)\.hovering$/;
+  // Polymer 1 adds # before array's key, while Polymer 2 doesn't
+  const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
 
-  const RANGE_HIGHLIGHT = 'range';
-  const HOVER_HIGHLIGHT = 'rangeHighlight';
-
-  /** @typedef {{side: string, range: Gerrit.Range, hovering: boolean}} */
-  Gerrit.HoveredRange;
+  const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
+  const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
 
   Polymer({
     is: 'gr-ranged-comment-layer',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the range in a range comment was malformed and had to be
@@ -55,6 +52,10 @@
       '_handleCommentRangesChange(commentRanges.*)',
     ],
 
+    get styleModuleName() {
+      return 'gr-ranged-comment-styles';
+    },
+
     /**
      * Layer method to add annotations to a line.
      *
@@ -130,8 +131,10 @@
       // If the change only changed the `hovering` property of a comment.
       const match = record.path.match(HOVER_PATH_PATTERN);
       if (match) {
-        const commentRangesIndex = match[1];
-        const {side, range, hovering} = this.commentRanges[commentRangesIndex];
+        // The #number indicates the key of that item in the array
+        // not the index, especially in polymer 1.
+        const {side, range, hovering} = this.get(match[1]);
+
         this._updateRangesMap(
             side, range, hovering, (forLine, start, end, hovering) => {
               const index = forLine.findIndex(lineRange =>
@@ -192,6 +195,7 @@
               range.end = line.text.length;
               this.dispatchEvent(new CustomEvent('normalize-range', {
                 bubbles: true,
+                composed: true,
                 detail: {lineNum, side},
               }));
             }
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 682c026..9d207a5 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ranged-comment-layer</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff/gr-diff-line.js"></script>
 
@@ -130,7 +132,7 @@
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'range');
+        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
       });
 
       test('type=Remove has-comment hovering', () => {
@@ -148,7 +150,7 @@
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'rangeHighlight');
+        assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
       });
 
       test('type=Both has-comment', () => {
@@ -165,7 +167,7 @@
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'range');
+        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
       });
 
       test('type=Both has-comment off side', () => {
@@ -193,7 +195,7 @@
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'range');
+        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
       });
     });
 
@@ -207,6 +209,7 @@
     test('_handleCommentRangesChange hovering', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
+      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
 
       element.set(['commentRanges', 1, 'hovering'], true);
 
@@ -215,6 +218,8 @@
       assert.equal(lastCall.args[0], 10);
       assert.equal(lastCall.args[1], 12);
       assert.equal(lastCall.args[2], 'right');
+
+      assert.isTrue(updateRangesMapSpy.called);
     });
 
     test('_handleCommentRangesChange splice out', () => {
@@ -251,6 +256,31 @@
       assert.equal(lastCall.args[2], 'left');
     });
 
+    test('_handleCommentRangesChange mixed actions', () => {
+      const notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+
+      element.set(['commentRanges', 1, 'hovering'], true);
+      assert.isTrue(updateRangesMapSpy.callCount === 1);
+      element.splice('commentRanges', 1, 1);
+      assert.isTrue(updateRangesMapSpy.callCount === 2);
+      element.splice('commentRanges', 1, 1);
+      assert.isTrue(updateRangesMapSpy.callCount === 3);
+      element.splice('commentRanges', 1, 0, {
+        side: 'left',
+        range: {
+          end_character: 15,
+          end_line: 275,
+          start_character: 5,
+          start_line: 250,
+        },
+      });
+      assert.isTrue(updateRangesMapSpy.callCount === 4);
+      element.set(['commentRanges', 2, 'hovering'], true);
+      assert.isTrue(updateRangesMapSpy.callCount === 5);
+    });
+
     test('_computeCommentMap creates maps correctly', () => {
       // There is only one ranged comment on the left, but it spans ll.36-39.
       const leftKeys = [];
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html
new file mode 100644
index 0000000..cefd241
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html
@@ -0,0 +1,30 @@
+<!--
+@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.
+-->
+<dom-module id="gr-ranged-comment-theme">
+  <template>
+    <style>
+      .range {
+        background-color: var(--diff-highlight-range-color);
+        display: inline;
+      }
+      .rangeHighlight {
+        background-color: var(--diff-highlight-range-hover-color);
+        display: inline;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 633530f..aa4d2e1 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 26bf738..b1b3e0f 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-selection-action-box',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the comment creation action was taken (hotkey, click).
@@ -49,6 +48,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index dece366..b950e7b 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-selection-action-box</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-selection-action-box.html">
 
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
index 67c32bb..dd6bfec 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-lib-loader/gr-lib-loader.html">
 
 <dom-module id="gr-syntax-layer">
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 944fa49..50cf6b4 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -134,7 +134,6 @@
 
   Polymer({
     is: 'gr-syntax-layer',
-    _legacyUndefinedCheck: true,
 
     properties: {
       diff: {
@@ -179,6 +178,10 @@
       this.push('_listeners', fn);
     },
 
+    removeListener(fn) {
+      this._listeners = this._listeners.filter(f => f != fn);
+    },
+
     /**
      * Annotation layer method to add syntax annotations to the given element
      * for the given line.
@@ -225,7 +228,7 @@
     },
 
     /**
-     * Start processing symtax for the loaded diff and notify layer listeners
+     * Start processing syntax for the loaded diff and notify layer listeners
      * as syntax info comes online.
      *
      * @return {Promise}
@@ -233,7 +236,7 @@
     process() {
       // Cancel any still running process() calls, because they append to the
       // same _baseRanges and _revisionRanges fields.
-      this.cancel();
+      this._cancel();
 
       // Discard existing ranges.
       this._baseRanges = [];
@@ -303,7 +306,7 @@
     /**
      * Cancel any asynchronous syntax processing jobs.
      */
-    cancel() {
+    _cancel() {
       if (this._processHandle != null) {
         this.cancelAsync(this._processHandle);
         this._processHandle = null;
@@ -314,7 +317,7 @@
     },
 
     _diffChanged() {
-      this.cancel();
+      this._cancel();
       this._baseRanges = [];
       this._revisionRanges = [];
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index b63675a..472db21 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-syntax-layer</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-syntax-layer.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
index 10710ae..e5ae06d 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
@@ -41,7 +41,6 @@
       .gr-syntax-keyword,
       .gr-syntax-name {
         color: var(--syntax-keyword-color);
-        line-height: 1;
       }
       .gr-syntax-number {
         color: var(--syntax-number-color);
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
index 720f353..5072b9d 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
@@ -14,11 +14,10 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
index 995326f..f850b9d 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-documentation-search',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
index 84addb0..84298e2 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-documentation-search</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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-documentation-search.html">
 
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
index 093e979..19a4e63 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-default-editor">
@@ -25,6 +25,8 @@
         border: none;
         box-sizing: border-box;
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-code);
         min-height: 60vh;
         resize: none;
         white-space: pre;
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index 01cd9df..ed96bb2 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-default-editor',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the content of the editor changes.
@@ -32,8 +31,9 @@
     },
 
     _handleTextareaInput(e) {
-      this.dispatchEvent(new CustomEvent('content-change',
-          {detail: {value: e.target.value}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'content-change',
+          {detail: {value: e.target.value}, bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
index b79cd9d..c986e7c 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-default-editor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-default-editor.html">
@@ -50,7 +52,7 @@
       element.addEventListener('content-change', contentChangedHandler);
       textarea.value = 'test';
       textarea.dispatchEvent(new CustomEvent('input',
-          {target: textarea, bubbles: true}));
+          {target: textarea, bubbles: true, composed: true}));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 81b3c07..52692a7 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -43,7 +43,7 @@
         display: none;
       }
       gr-button {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
         text-decoration: none;
       }
       gr-dialog {
@@ -52,22 +52,23 @@
       gr-dialog .main {
         width: 100%;
       }
+      gr-dialog .main > iron-input{
+        width: 100%;
+      }
       gr-autocomplete {
         --gr-autocomplete: {
           border: 1px solid var(--border-color);
-          border-radius: 2px;
-          font-size: var(--font-size-normal);
+          border-radius: var(--border-radius);
           height: 2em;
-          padding: 0 .15em;
+          padding: 0 var(--spacing-xs);
         }
       }
       input {
         border: 1px solid var(--border-color);
-        border-radius: 2px;
-        font-size: var(--font-size-normal);
+        border-radius: var(--border-radius);
         height: 2em;
-        margin: .5em 0;
-        padding: 0 .15em;
+        margin: var(--spacing-m) 0;
+        padding: 0 var(--spacing-xs);
         width: 100%;
       }
       @media screen and (max-width: 50em) {
@@ -81,7 +82,7 @@
           id$="[[action.id]]"
           class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
           link
-          on-tap="_handleTap">[[action.label]]</gr-button>
+          on-click="_handleTap">[[action.label]]</gr-button>
     </template>
     <gr-overlay id="overlay" with-backdrop>
       <gr-dialog
@@ -132,11 +133,16 @@
               placeholder="Enter an existing full file path."
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
-          <input
-              class="newPathInput"
-              is="iron-input"
+          <iron-input
+              class="newPathIronInput"
               bind-value="{{_newPath}}"
-              placeholder="Enter the new path."/>
+              placeholder="Enter the new path.">
+            <input
+                class="newPathInput"
+                is="iron-input"
+                bind-value="{{_newPath}}"
+                placeholder="Enter the new path.">
+          </iron-input>
         </div>
       </gr-dialog>
       <gr-dialog
@@ -148,10 +154,14 @@
           on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Restore this file?</div>
         <div class="main" slot="main">
-          <input
-              is="iron-input"
+          <iron-input
               disabled
-              bind-value="{{_path}}"/>
+              bind-value="{{_path}}">
+            <input
+                is="iron-input"
+                disabled
+                bind-value="{{_path}}">
+          </iron-input>
         </div>
       </gr-dialog>
     </gr-overlay>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
index 3567d06..b7e12fe 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-edit-controls',
-    _legacyUndefinedCheck: true,
+
     properties: {
       change: Object,
       patchNum: String,
@@ -170,7 +170,8 @@
         // just make two separate queries.
         dialog.querySelectorAll('gr-autocomplete')
             .forEach(input => { input.text = ''; });
-        dialog.querySelectorAll('input')
+
+        dialog.querySelectorAll('iron-input')
             .forEach(input => { input.bindValue = ''; });
       }
 
@@ -190,28 +191,33 @@
     },
 
     _handleDeleteConfirm(e) {
+      // Get the dialog before the api call as the event will change during bubbling
+      // which will make Polymer.dom(e).path an emtpy array in polymer 2
+      const dialog = this._getDialogFromEvent(e);
       this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
           .then(res => {
             if (!res.ok) { return; }
-            this._closeDialog(this._getDialogFromEvent(e), true);
+            this._closeDialog(dialog, true);
             Gerrit.Nav.navigateToChange(this.change);
           });
     },
 
     _handleRestoreConfirm(e) {
+      const dialog = this._getDialogFromEvent(e);
       this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
           .then(res => {
             if (!res.ok) { return; }
-            this._closeDialog(this._getDialogFromEvent(e), true);
+            this._closeDialog(dialog, true);
             Gerrit.Nav.navigateToChange(this.change);
           });
     },
 
     _handleRenameConfirm(e) {
+      const dialog = this._getDialogFromEvent(e);
       return this.$.restAPI.renameFileInChangeEdit(this.change._number,
           this._path, this._newPath).then(res => {
         if (!res.ok) { return; }
-        this._closeDialog(this._getDialogFromEvent(e), true);
+        this._closeDialog(dialog, true);
         Gerrit.Nav.navigateToChange(this.change);
       });
     },
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
index c67a2af..dd8cb74 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-controls</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-edit-controls.html">
@@ -189,6 +191,9 @@
     let navStub;
     let renameStub;
     let renameAutocomplete;
+    const inputSelector = Polymer.Element ?
+      '.newPathIronInput' :
+      '.newPathInput';
 
     setup(() => {
       navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
@@ -208,7 +213,7 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
@@ -236,7 +241,7 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
@@ -258,7 +263,7 @@
         assert.isTrue(element.$.renameDialog.disabled);
         element.$.renameDialog.querySelector('gr-autocomplete').text =
             'src/test.cpp';
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
         assert.isFalse(element.$.renameDialog.disabled);
         MockInteractions.tap(element.$.renameDialog.$$('gr-button'));
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
index c57a147..f6c7803 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.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">
@@ -32,7 +32,7 @@
         justify-content: flex-end;
       }
       #actions {
-        margin-right: 1em;
+        margin-right: var(--spacing-l);
       }
       gr-button,
       gr-dropdown {
@@ -45,7 +45,6 @@
           background-color: transparent;
           border: none;
           color: var(--link-color);
-          font-size: inherit;
           text-transform: uppercase;
         }
       }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index 9407f18..250816b 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-edit-file-controls',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when an action in the overflow menu is tapped.
@@ -46,8 +45,9 @@
     },
 
     _dispatchFileAction(action, path) {
-      this.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action, path}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'file-action-tap',
+          {detail: {action, path}, bubbles: true, composed: true}));
     },
 
     _computeFileActions(actions) {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
index 12d9e0b..7979e57 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-file-controls</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-edit-constants.html">
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
index b107221..ce90c69 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
@@ -47,23 +48,23 @@
         align-items: center;
         display: flex;
         justify-content: space-between;
-        padding: .75em var(--default-horizontal-margin);
+        padding: var(--spacing-m) var(--spacing-l);
       }
       header gr-editable-label {
-        font-size: var(--font-size-large);
+        font-size: var(--font-size-h3);
         --label-style: {
           text-overflow: initial;
           white-space: initial;
           word-break: break-all;
         }
         --input-style: {
-          margin-top: 1em;
+          margin-top: var(--spacing-l);
         }
       }
       .textareaWrapper {
         border: 1px solid var(--border-color);
-        border-radius: 3px;
-        margin: var(--default-horizontal-margin);
+        border-radius: var(--border-radius);
+        margin: var(--spacing-l);
       }
       .textareaWrapper .editButtons {
         display: none;
@@ -71,7 +72,7 @@
       .controlGroup {
         align-items: center;
         display: flex;
-        font-size: var(--font-size-large);
+        font-size: var(--font-size-h3);
       }
       .rightControls {
         justify-content: flex-end;
@@ -101,13 +102,13 @@
           <gr-button
               id="close"
               link
-              on-tap="_handleCloseTap">Close</gr-button>
+              on-click="_handleCloseTap">Close</gr-button>
           <gr-button
               id="save"
               disabled$="[[_saveDisabled]]"
               primary
               link
-              on-tap="_saveEdit">Save</gr-button>
+              on-click="_saveEdit">Save</gr-button>
         </span>
       </header>
     </gr-fixed-panel>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index 3ed165e..a21975d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -26,7 +26,6 @@
 
   Polymer({
     is: 'gr-editor-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -75,6 +74,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.PathListBehavior,
@@ -105,7 +105,9 @@
     },
 
     _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+      if (value.view !== Gerrit.Nav.View.EDIT) {
+        return;
+      }
 
       this._changeNum = value.changeNum;
       this._path = value.path;
@@ -136,7 +138,9 @@
 
     _handlePathChanged(e) {
       const path = e.detail;
-      if (path === this._path) { return Promise.resolve(); }
+      if (path === this._path) {
+        return Promise.resolve();
+      }
       return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
           this._path, path).then(res => {
         if (!res.ok) { return; }
@@ -160,8 +164,11 @@
           .then(res => {
             if (storedContent && storedContent.message &&
                 storedContent.message !== res.content) {
-              this.dispatchEvent(new CustomEvent('show-alert',
-                  {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: RESTORED_MESSAGE},
+                bubbles: true,
+                composed: true,
+              }));
 
               this._newContent = storedContent.message;
             } else {
@@ -199,11 +206,23 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message},
         bubbles: true,
+        composed: true,
       }));
     },
 
     _computeSaveDisabled(content, newContent, saving) {
-      if (saving) { return true; }
+      // Polymer 2: check for undefined
+      if ([
+        content,
+        newContent,
+        saving,
+      ].some(arg => arg === undefined)) {
+        return true;
+      }
+
+      if (saving) {
+        return true;
+      }
       return content === newContent;
     },
 
@@ -226,7 +245,9 @@
 
     _handleSaveShortcut(e) {
       e.preventDefault();
-      if (!this._saveDisabled) { this._saveEdit(); }
+      if (!this._saveDisabled) {
+        this._saveEdit();
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 010ff4a..226472f 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editor-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-editor-view.html">
@@ -121,7 +123,7 @@
     const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true,
+      bubbles: true, composed: true,
       detail: {value: 'new content value'},
     }));
     element.flushDebouncer('store');
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
new file mode 100644
index 0000000..046e5ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.html
@@ -0,0 +1,238 @@
+<!--
+@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.
+-->
+<script src="/bower_components/moment/moment.js"></script>
+<script src="../scripts/util.js"></script>
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../styles/shared-styles.html">
+<link rel="import" href="../styles/themes/app-theme.html">
+<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
+<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
+<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
+<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
+<link rel="import" href="./change/gr-change-view/gr-change-view.html">
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
+<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
+<link rel="import" href="./core/gr-main-header/gr-main-header.html">
+<link rel="import" href="./core/gr-navigation/gr-navigation.html">
+<link rel="import" href="./core/gr-reporting/gr-reporting.html">
+<link rel="import" href="./core/gr-router/gr-router.html">
+<link rel="import" href="./core/gr-smart-search/gr-smart-search.html">
+<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
+<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
+<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
+<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
+<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
+<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
+<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
+<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
+<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-app-element">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--view-background-color);
+        display: flex;
+        flex-direction: column;
+        min-height: 100%;
+      }
+      gr-fixed-panel {
+        /**
+         * This one should be greater that the z-index in gr-diff-view
+         * because gr-main-header contains overlay.
+         */
+        z-index: 10;
+      }
+      gr-main-header,
+      footer {
+        color: var(--primary-text-color);
+      }
+      gr-main-header {
+        background: var(--header-background, var(--header-background-color, #eee));
+        padding: var(--header-padding);
+        border-bottom: var(--header-border-bottom);
+        border-image: var(--header-border-image);
+        border-right: 0;
+        border-left: 0;
+        border-top: 0;
+        box-shadow: var(--header-box-shadow);
+      }
+      footer {
+        background: var(--footer-background, var(--footer-background-color, #eee));
+        border-top: var(--footer-border-top);
+        display: flex;
+        justify-content: space-between;
+        padding: var(--spacing-m) var(--spacing-l);
+        z-index: 100;
+      }
+      main {
+        flex: 1;
+        padding-bottom: var(--spacing-xxl);
+        position: relative;
+      }
+      .errorView {
+        align-items: center;
+        display: none;
+        flex-direction: column;
+        justify-content: center;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+      }
+      .errorView.show {
+        display: flex;
+      }
+      .errorEmoji {
+        font-size: 2.6rem;
+      }
+      .errorText,
+      .errorMoreInfo {
+        margin-top: var(--spacing-m);
+      }
+      .errorText {
+        font-size: var(--font-size-h3);
+      }
+      .errorMoreInfo {
+        color: var(--deemphasized-text-color);
+      }
+      .feedback {
+        color: var(--error-text-color);
+      }
+    </style>
+    <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+    <gr-fixed-panel id="header">
+      <gr-main-header
+          id="mainHeader"
+          search-query="{{params.query}}"
+          on-mobile-search="_mobileSearchToggle">
+      </gr-main-header>
+    </gr-fixed-panel>
+    <main>
+      <gr-smart-search
+          id="search"
+          search-query="{{params.query}}"
+          hidden="[[!mobileSearch]]">
+      </gr-smart-search>
+      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
+        <gr-change-list-view
+            params="[[params]]"
+            account="[[_account]]"
+            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
+      </template>
+      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
+        <gr-dashboard-view
+            account="[[_account]]"
+            params="[[params]]"
+            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
+      </template>
+      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+        <gr-change-view
+            params="[[params]]"
+            view-state="{{_viewState.changeView}}"
+            back-page="[[_lastSearchPage]]"></gr-change-view>
+      </template>
+      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+        <gr-editor-view
+            params="[[params]]"></gr-editor-view>
+      </template>
+      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+          <gr-diff-view
+              params="[[params]]"
+              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+        </template>
+      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
+        <gr-settings-view
+            params="[[params]]"
+            on-account-detail-update="_handleAccountDetailUpdate">
+        </gr-settings-view>
+      </template>
+      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
+        <gr-admin-view path="[[_path]]"
+            params=[[params]]></gr-admin-view>
+      </template>
+      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
+        <gr-endpoint-decorator name="[[_pluginScreenName]]">
+          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
+      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
+        <gr-cla-view></gr-cla-view>
+      </template>
+      <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
+        <gr-documentation-search
+            params="[[params]]">
+        </gr-documentation-search>
+      </template>
+      <div id="errorView" class="errorView">
+        <div class="errorEmoji">[[_lastError.emoji]]</div>
+        <div class="errorText">[[_lastError.text]]</div>
+        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
+      </div>
+    </main>
+    <footer r="contentinfo">
+      <div>
+        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
+        target="_blank">Gerrit Code Review</a>
+        ([[_version]])
+        <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+      </div>
+      <div>
+        <template is="dom-if" if="[[_feedbackUrl]]">
+          <a class="feedback"
+              href$="[[_feedbackUrl]]"
+              rel="noopener"
+              target="_blank">Report bug</a> |
+        </template>
+        Press &ldquo;?&rdquo; for keyboard shortcuts
+        <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+      </div>
+    </footer>
+    <gr-overlay id="keyboardShortcuts" with-backdrop>
+      <gr-keyboard-shortcuts-dialog
+          view="[[params.view]]"
+          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
+    </gr-overlay>
+    <gr-overlay id="registrationOverlay" with-backdrop>
+      <gr-registration-dialog
+          id="registrationDialog"
+          settings-url="[[_settingsUrl]]"
+          on-account-detail-update="_handleAccountDetailUpdate"
+          on-close="_handleRegistrationDialogClose">
+      </gr-registration-dialog>
+    </gr-overlay>
+    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+    <gr-error-manager id="errorManager"></gr-error-manager>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting"></gr-reporting>
+    <gr-router id="router"></gr-router>
+    <gr-plugin-host id="plugins"
+        config="[[_serverConfig]]">
+    </gr-plugin-host>
+    <gr-lib-loader id="libLoader"></gr-lib-loader>
+    <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
new file mode 100644
index 0000000..ce6b98b
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -0,0 +1,468 @@
+/**
+ * @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() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-app-element',
+
+    /**
+     * Fired when the URL location changes.
+     *
+     * @event location-change
+     */
+
+    properties: {
+      /**
+       * @type {{ query: string, view: string, screen: string }}
+       */
+      params: Object,
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+
+      _account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+
+      /**
+       * The last time the g key was pressed in milliseconds (or a keydown event
+       * was handled if the key is held down).
+       *
+       * @type {number|null}
+       */
+      _lastGKeyPressTimestamp: {
+        type: Number,
+        value: null,
+      },
+
+      /**
+       * @type {{ plugin: Object }}
+       */
+      _serverConfig: Object,
+      _version: String,
+      _showChangeListView: Boolean,
+      _showDashboardView: Boolean,
+      _showChangeView: Boolean,
+      _showDiffView: Boolean,
+      _showSettingsView: Boolean,
+      _showAdminView: Boolean,
+      _showCLAView: Boolean,
+      _showEditorView: Boolean,
+      _showPluginScreen: Boolean,
+      _showDocumentationSearch: Boolean,
+      /** @type {?} */
+      _viewState: Object,
+      /** @type {?} */
+      _lastError: Object,
+      _lastSearchPage: String,
+      _path: String,
+      _pluginScreenName: {
+        type: String,
+        computed: '_computePluginScreenName(params)',
+      },
+      _settingsUrl: String,
+      _feedbackUrl: String,
+      // Used to allow searching on mobile
+      mobileSearch: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    listeners: {
+      'page-error': '_handlePageError',
+      'title-change': '_handleTitleChange',
+      'location-change': '_handleLocationChange',
+      'rpc-log': '_handleRpcLog',
+    },
+
+    observers: [
+      '_viewChanged(params.view)',
+      '_paramsChanged(params.*)',
+    ],
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+        [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+        [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+        [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+      };
+    },
+
+    created() {
+      this._bindKeyboardShortcuts();
+    },
+
+    ready() {
+      this.$.reporting.appStarted(document.visibilityState === 'hidden');
+      this.$.router.start();
+
+      this.$.restAPI.getAccount().then(account => {
+        this._account = account;
+      });
+      this.$.restAPI.getConfig().then(config => {
+        this._serverConfig = config;
+
+        if (config && config.gerrit && config.gerrit.report_bug_url) {
+          this._feedbackUrl = config.gerrit.report_bug_url;
+        }
+      });
+      this.$.restAPI.getVersion().then(version => {
+        this._version = version;
+        this._logWelcome();
+      });
+
+      if (window.localStorage.getItem('dark-theme')) {
+        // No need to add the style module to element again as it's imported
+        // by importHref already
+        this.$.libLoader.getDarkTheme();
+      }
+
+      // Note: this is evaluated here to ensure that it only happens after the
+      // router has been initialized. @see Issue 7837
+      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
+
+      this._viewState = {
+        changeView: {
+          changeNum: null,
+          patchRange: null,
+          selectedFileIndex: 0,
+          showReplyDialog: false,
+          diffMode: null,
+          numFilesShown: null,
+          scrollTop: 0,
+        },
+        changeListView: {
+          query: null,
+          offset: 0,
+          selectedChangeIndex: 0,
+        },
+        dashboardView: {
+          selectedChangeIndex: 0,
+        },
+      };
+    },
+
+    _bindKeyboardShortcuts() {
+      this.bindShortcut(this.Shortcut.SEND_REPLY,
+          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+      this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
+          this.DOC_ONLY, ':');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
+
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+      this.bindShortcut(
+          this.Shortcut.OPEN_CHANGE, 'o');
+      this.bindShortcut(
+          this.Shortcut.NEXT_PAGE, 'n', ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_PAGE, 'p', '[');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+      this.bindShortcut(
+          this.Shortcut.EDIT_TOPIC, 't');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_DASHBOARD, 'u');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_CHANGE, 'u');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_LINE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.PREV_LINE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.NEXT_CHUNK, 'n');
+      this.bindShortcut(
+          this.Shortcut.PREV_CHUNK, 'p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      this.bindShortcut(
+          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+      this.bindShortcut(
+          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+          this.DOC_ONLY, 'shift+e');
+      this.bindShortcut(
+          this.Shortcut.LEFT_PANE, 'shift+left');
+      this.bindShortcut(
+          this.Shortcut.RIGHT_PANE, 'shift+right');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+      this.bindShortcut(
+          this.Shortcut.NEW_COMMENT, 'c');
+      this.bindShortcut(
+          this.Shortcut.SAVE_COMMENT,
+          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DIFF_PREFS, ',');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE, '[');
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.OPEN_FILE, 'o', 'enter');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+      this.bindShortcut(
+          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_FIRST_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.OPEN_LAST_FILE, '[');
+
+      this.bindShortcut(
+          this.Shortcut.SEARCH, '/');
+    },
+
+    _accountChanged(account) {
+      if (!account) { return; }
+
+      // Preferences are cached when a user is logged in; warm them.
+      this.$.restAPI.getPreferences();
+      this.$.restAPI.getDiffPreferences();
+      this.$.restAPI.getEditPreferences();
+      this.$.errorManager.knownAccountId =
+          this._account && this._account._account_id || null;
+    },
+
+    _viewChanged(view) {
+      this.$.errorView.classList.remove('show');
+      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
+      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
+      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
+      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
+      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
+      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
+          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
+      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
+      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
+      this.set('_showPluginScreen', false);
+      // Navigation within plugin screens does not restamp gr-endpoint-decorator
+      // because _showPluginScreen value does not change. To force restamp,
+      // change _showPluginScreen value between true and false.
+      if (isPluginScreen) {
+        this.async(() => this.set('_showPluginScreen', true), 1);
+      }
+      this.set('_showDocumentationSearch',
+          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
+      if (this.params.justRegistered) {
+        this.$.registrationOverlay.open();
+        this.$.registrationDialog.loadData().then(() => {
+          this.$.registrationOverlay.refit();
+        });
+      }
+      this.$.header.unfloat();
+    },
+
+    _handlePageError(e) {
+      const props = [
+        '_showChangeListView',
+        '_showDashboardView',
+        '_showChangeView',
+        '_showDiffView',
+        '_showSettingsView',
+        '_showAdminView',
+      ];
+      for (const showProp of props) {
+        this.set(showProp, false);
+      }
+
+      this.$.errorView.classList.add('show');
+      const response = e.detail.response;
+      const err = {text: [response.status, response.statusText].join(' ')};
+      if (response.status === 404) {
+        err.emoji = '¯\\_(ツ)_/¯';
+        this._lastError = err;
+      } else {
+        err.emoji = 'o_O';
+        response.text().then(text => {
+          err.moreInfo = text;
+          this._lastError = err;
+        });
+      }
+    },
+
+    _handleLocationChange(e) {
+      const hash = e.detail.hash.substring(1);
+      let pathname = e.detail.pathname;
+      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
+        pathname += '@' + hash;
+      }
+      this.set('_path', pathname);
+    },
+
+    _paramsChanged(paramsRecord) {
+      const params = paramsRecord.base;
+      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
+      if (viewsToCheck.includes(params.view)) {
+        this.set('_lastSearchPage', location.pathname);
+      }
+    },
+
+    _handleTitleChange(e) {
+      if (e.detail.title) {
+        document.title = e.detail.title + ' · Gerrit Code Review';
+      } else {
+        document.title = '';
+      }
+    },
+
+    _showKeyboardShortcuts(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this.$.keyboardShortcuts.open();
+    },
+
+    _handleKeyboardShortcutDialogClose() {
+      this.$.keyboardShortcuts.close();
+    },
+
+    _handleAccountDetailUpdate(e) {
+      this.$.mainHeader.reload();
+      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
+        this.$$('gr-settings-view').reloadAccountDetail();
+      }
+    },
+
+    _handleRegistrationDialogClose(e) {
+      this.params.justRegistered = false;
+      this.$.registrationOverlay.close();
+    },
+
+    _goToOpenedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('open');
+    },
+
+    _goToUserDashboard() {
+      Gerrit.Nav.navigateToUserDashboard();
+    },
+
+    _goToMergedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('merged');
+    },
+
+    _goToAbandonedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('abandoned');
+    },
+
+    _goToWatchedChanges() {
+      // The query is hardcoded, and doesn't respect custom menu entries
+      Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
+    },
+
+    _computePluginScreenName({plugin, screen}) {
+      if (!plugin || !screen) return '';
+      return `${plugin}-screen-${screen}`;
+    },
+
+    _logWelcome() {
+      console.group('Runtime Info');
+      console.log('Gerrit UI (PolyGerrit)');
+      console.log(`Gerrit Server Version: ${this._version}`);
+      if (window.VERSION_INFO) {
+        console.log(`UI Version Info: ${window.VERSION_INFO}`);
+      }
+      if (this._feedbackUrl) {
+        console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+      }
+      console.groupEnd();
+    },
+
+    /**
+     * Intercept RPC log events emitted by REST API interfaces.
+     * Note: the REST API interface cannot use gr-reporting directly because
+     * that would create a cyclic dependency.
+     */
+    _handleRpcLog(e) {
+      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+          e.detail.elapsed);
+    },
+
+    _mobileSearchToggle(e) {
+      this.mobileSearch = !this.mobileSearch;
+    },
+
+    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/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 8c50358..f49d8aa 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -15,28 +15,18 @@
 limitations under the License.
 -->
 <script>
-  if (!window.POLYMER2) {
-    // This must be set prior to loading Polymer for the first time.
-    if (localStorage.getItem('USE_SHADOW_DOM') === 'true') {
-      window.Polymer = {
-        dom: 'shadow',
-        passiveTouchGestures: true,
-      };
-    } else if (!window.Polymer) {
-      window.Polymer = {
-        passiveTouchGestures: true,
-      };
-    }
+  if (!window.Polymer) {
+    window.Polymer = {
+      passiveTouchGestures: true,
+      lazyRegister: true,
+    };
   }
-  // Needed for JSCompiler to understand it's global.
-  // eslint-disable-next-line no-unused-vars, prefer-const
-  let Gerrit = window.Gerrit || {};
-  window.Gerrit = Gerrit;
+  window.Gerrit = window.Gerrit || {};
 </script>
 
-<link rel="import" href="../bower_components/polymer/polymer.html">
-<link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
-<link rel="import" href="../bower_components/polymer/lib/legacy/legacy-data-mixin.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer-resin/standalone/polymer-resin.html">
+<!-- TODO(taoalpha): Remove once all legacyUndefinedCheck removed. -->
 <link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
@@ -45,228 +35,11 @@
     safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
-<script src="../bower_components/moment/moment.js"></script>
 
-<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../styles/shared-styles.html">
-<link rel="import" href="../styles/themes/app-theme.html">
-<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
-<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="./change/gr-change-view/gr-change-view.html">
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.html">
-<link rel="import" href="./core/gr-navigation/gr-navigation.html">
-<link rel="import" href="./core/gr-reporting/gr-reporting.html">
-<link rel="import" href="./core/gr-router/gr-router.html">
-<link rel="import" href="./core/gr-smart-search/gr-smart-search.html">
-<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
-<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
-<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
-<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
-<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
-<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
-<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<script src="../scripts/util.js"></script>
-
+<link rel="import" href="./gr-app-element.html">
 <dom-module id="gr-app">
   <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-        display: flex;
-        flex-direction: column;
-        min-height: 100%;
-      }
-      gr-fixed-panel {
-        /**
-         * This one should be greater that the z-index in gr-diff-view
-         * because gr-main-header contains overlay.
-         */
-        z-index: 10;
-      }
-      gr-main-header,
-      footer {
-        color: var(--primary-text-color);
-      }
-      gr-main-header {
-        background: var(--header-background, var(--header-background-color, #eee));
-        padding: 0 var(--default-horizontal-margin);
-        border-bottom: var(--header-border-bottom);
-        border-image: var(--header-border-image);
-        border-right: 0;
-        border-left: 0;
-        border-top: 0;
-      }
-      gr-main-header.shadow {
-        /* Make it obvious for shadow dom testing */
-        border-bottom: 1px solid pink;
-      }
-      footer {
-        background: var(--footer-background, var(--footer-background-color, #eee));
-        border-top: var(--footer-border-top);
-        display: flex;
-        justify-content: space-between;
-        padding: .5rem var(--default-horizontal-margin);
-        z-index: 100;
-      }
-      main {
-        flex: 1;
-        padding-bottom: 2em;
-        position: relative;
-      }
-      .errorView {
-        align-items: center;
-        display: none;
-        flex-direction: column;
-        justify-content: center;
-        position: absolute;
-        top: 0;
-        right: 0;
-        bottom: 0;
-        left: 0;
-      }
-      .errorView.show {
-        display: flex;
-      }
-      .errorEmoji {
-        font-size: 2.6rem;
-      }
-      .errorText,
-      .errorMoreInfo {
-        margin-top: .75em;
-      }
-      .errorText {
-        font-size: 1.2rem;
-      }
-      .errorMoreInfo {
-        color: var(--deemphasized-text-color);
-      }
-      .feedback {
-        color: var(--error-text-color);
-      }
-    </style>
-    <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-    <gr-fixed-panel id="header">
-      <gr-main-header
-          id="mainHeader"
-          search-query="{{params.query}}"
-          class$="[[_computeShadowClass(_isShadowDom)]]"
-          on-mobile-search="_mobileSearchToggle">
-      </gr-main-header>
-    </gr-fixed-panel>
-    <main>
-      <gr-smart-search
-          id="search"
-          search-query="{{params.query}}"
-          hidden="[[!mobileSearch]]">
-      </gr-smart-search>
-      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-        <gr-change-list-view
-            params="[[params]]"
-            account="[[_account]]"
-            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
-      </template>
-      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-        <gr-dashboard-view
-            account="[[_account]]"
-            params="[[params]]"
-            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
-      </template>
-      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
-        <gr-change-view
-            params="[[params]]"
-            view-state="{{_viewState.changeView}}"
-            back-page="[[_lastSearchPage]]"></gr-change-view>
-      </template>
-      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-        <gr-editor-view
-            params="[[params]]"></gr-editor-view>
-      </template>
-      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-          <gr-diff-view
-              params="[[params]]"
-              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
-        </template>
-      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view
-            params="[[params]]"
-            on-account-detail-update="_handleAccountDetailUpdate">
-        </gr-settings-view>
-      </template>
-      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-        <gr-admin-view path="[[_path]]"
-            params=[[params]]></gr-admin-view>
-      </template>
-      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-        <gr-endpoint-decorator name="[[_pluginScreenName]]">
-          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-        <gr-cla-view></gr-cla-view>
-      </template>
-      <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-        <gr-documentation-search
-            params="[[params]]">
-        </gr-documentation-search>
-      </template>
-      <div id="errorView" class="errorView">
-        <div class="errorEmoji">[[_lastError.emoji]]</div>
-        <div class="errorText">[[_lastError.text]]</div>
-        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-      </div>
-    </main>
-    <footer r="contentinfo" class$="[[_computeShadowClass(_isShadowDom)]]">
-      <div>
-        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
-        target="_blank">Gerrit Code Review</a>
-        ([[_version]])
-        <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
-      </div>
-      <div>
-        <template is="dom-if" if="[[_feedbackUrl]]">
-          <a class="feedback"
-              href$="[[_feedbackUrl]]"
-              rel="noopener"
-              target="_blank">Send feedback</a> |
-        </template>
-        Press &ldquo;?&rdquo; for keyboard shortcuts
-        <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
-      </div>
-    </footer>
-    <gr-overlay id="keyboardShortcuts" with-backdrop>
-      <gr-keyboard-shortcuts-dialog
-          view="[[params.view]]"
-          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
-    </gr-overlay>
-    <gr-overlay id="registrationOverlay" with-backdrop>
-      <gr-registration-dialog
-          id="registrationDialog"
-          settings-url="[[_settingsUrl]]"
-          on-account-detail-update="_handleAccountDetailUpdate"
-          on-close="_handleRegistrationDialogClose">
-      </gr-registration-dialog>
-    </gr-overlay>
-    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-    <gr-error-manager id="errorManager"></gr-error-manager>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-    <gr-router id="router"></gr-router>
-    <gr-plugin-host id="plugins"
-        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-app-element id="app-element"></gr-app-element>
   </template>
   <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index b89d90e..ac8ea1a 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -17,461 +17,7 @@
 (function() {
   'use strict';
 
-  // Eagerly render Polymer components when backgrounded. (Skips
-  // requestAnimationFrame.)
-  // @see https://github.com/Polymer/polymer/issues/3851
-  // @see Issue 4699
-  if (!window.POLYMER2) {
-    Polymer.RenderStatus._makeReady();
-  }
-
   Polymer({
     is: 'gr-app',
-    _legacyUndefinedCheck: true,
-
-    /**
-     * Fired when the URL location changes.
-     *
-     * @event location-change
-     */
-
-    properties: {
-      /**
-       * @type {{ query: string, view: string, screen: string }}
-       */
-      params: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-
-      _account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
-
-      /**
-       * The last time the g key was pressed in milliseconds (or a keydown event
-       * was handled if the key is held down).
-       *
-       * @type {number|null}
-       */
-      _lastGKeyPressTimestamp: {
-        type: Number,
-        value: null,
-      },
-
-      /**
-       * @type {{ plugin: Object }}
-       */
-      _serverConfig: Object,
-      _version: String,
-      _showChangeListView: Boolean,
-      _showDashboardView: Boolean,
-      _showChangeView: Boolean,
-      _showDiffView: Boolean,
-      _showSettingsView: Boolean,
-      _showAdminView: Boolean,
-      _showCLAView: Boolean,
-      _showEditorView: Boolean,
-      _showPluginScreen: Boolean,
-      _showDocumentationSearch: Boolean,
-      /** @type {?} */
-      _viewState: Object,
-      /** @type {?} */
-      _lastError: Object,
-      _lastSearchPage: String,
-      _path: String,
-      _isShadowDom: Boolean,
-      _pluginScreenName: {
-        type: String,
-        computed: '_computePluginScreenName(params)',
-      },
-      _settingsUrl: String,
-      _feedbackUrl: String,
-      // Used to allow searching on mobile
-      mobileSearch: {
-        type: Boolean,
-        value: false,
-      },
-    },
-
-    listeners: {
-      'page-error': '_handlePageError',
-      'title-change': '_handleTitleChange',
-      'location-change': '_handleLocationChange',
-      'rpc-log': '_handleRpcLog',
-    },
-
-    observers: [
-      '_viewChanged(params.view)',
-      '_paramsChanged(params.*)',
-    ],
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-        [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-        [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-        [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
-      };
-    },
-
-    created() {
-      this._bindKeyboardShortcuts();
-    },
-
-    ready() {
-      this._isShadowDom = Polymer.Settings.useShadow;
-      this.$.router.start();
-
-      this.$.restAPI.getAccount().then(account => {
-        this._account = account;
-      });
-      this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-
-        if (config && config.gerrit && config.gerrit.report_bug_url) {
-          this._feedbackUrl = config.gerrit.report_bug_url;
-        }
-      });
-      this.$.restAPI.getVersion().then(version => {
-        this._version = version;
-        this._logWelcome();
-      });
-
-      if (window.localStorage.getItem('dark-theme')) {
-        this.$.libLoader.getDarkTheme().then(module => {
-          Polymer.dom(this.root).appendChild(module);
-        });
-      }
-
-      // Note: this is evaluated here to ensure that it only happens after the
-      // router has been initialized. @see Issue 7837
-      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
-
-      this.$.reporting.appStarted(document.visibilityState === 'hidden');
-
-      this._viewState = {
-        changeView: {
-          changeNum: null,
-          patchRange: null,
-          selectedFileIndex: 0,
-          showReplyDialog: false,
-          diffMode: null,
-          numFilesShown: null,
-          scrollTop: 0,
-        },
-        changeListView: {
-          query: null,
-          offset: 0,
-          selectedChangeIndex: 0,
-        },
-        dashboardView: {
-          selectedChangeIndex: 0,
-        },
-      };
-    },
-
-    _bindKeyboardShortcuts() {
-      this.bindShortcut(this.Shortcut.SEND_REPLY,
-          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
-
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
-      this.bindShortcut(
-          this.Shortcut.OPEN_CHANGE, 'o');
-      this.bindShortcut(
-          this.Shortcut.NEXT_PAGE, 'n', ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_PAGE, 'p', '[');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_STAR, 's');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-      this.bindShortcut(
-          this.Shortcut.EDIT_TOPIC, 't');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE, 'shift+r');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_DASHBOARD, 'u');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_CHANGE, 'u');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_MODE, 'm');
-
-      this.bindShortcut(
-          this.Shortcut.NEXT_LINE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.PREV_LINE, 'k', 'up');
-      this.bindShortcut(
-          this.Shortcut.NEXT_CHUNK, 'n');
-      this.bindShortcut(
-          this.Shortcut.PREV_CHUNK, 'p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-      this.bindShortcut(
-          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      this.bindShortcut(
-          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-          this.DOC_ONLY, 'shift+e');
-      this.bindShortcut(
-          this.Shortcut.LEFT_PANE, 'shift+left');
-      this.bindShortcut(
-          this.Shortcut.RIGHT_PANE, 'shift+right');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      this.bindShortcut(
-          this.Shortcut.NEW_COMMENT, 'c');
-      this.bindShortcut(
-          this.Shortcut.SAVE_COMMENT,
-          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DIFF_PREFS, ',');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r');
-
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE, '[');
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-      this.bindShortcut(
-          this.Shortcut.OPEN_FILE, 'o', 'enter');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      this.bindShortcut(
-          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_FIRST_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.OPEN_LAST_FILE, '[');
-
-      this.bindShortcut(
-          this.Shortcut.SEARCH, '/');
-    },
-
-    _accountChanged(account) {
-      if (!account) { return; }
-
-      // Preferences are cached when a user is logged in; warm them.
-      this.$.restAPI.getPreferences();
-      this.$.restAPI.getDiffPreferences();
-      this.$.restAPI.getEditPreferences();
-      this.$.errorManager.knownAccountId =
-          this._account && this._account._account_id || null;
-    },
-
-    _viewChanged(view) {
-      this.$.errorView.classList.remove('show');
-      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
-      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
-      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
-      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
-      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
-      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
-          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
-      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
-      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
-      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
-      this.set('_showPluginScreen', false);
-      // Navigation within plugin screens does not restamp gr-endpoint-decorator
-      // because _showPluginScreen value does not change. To force restamp,
-      // change _showPluginScreen value between true and false.
-      if (isPluginScreen) {
-        this.async(() => this.set('_showPluginScreen', true), 1);
-      }
-      this.set('_showDocumentationSearch',
-          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
-      if (this.params.justRegistered) {
-        this.$.registrationOverlay.open();
-        this.$.registrationDialog.loadData().then(() => {
-          this.$.registrationOverlay.refit();
-        });
-      }
-      this.$.header.unfloat();
-    },
-
-    _handlePageError(e) {
-      const props = [
-        '_showChangeListView',
-        '_showDashboardView',
-        '_showChangeView',
-        '_showDiffView',
-        '_showSettingsView',
-        '_showAdminView',
-      ];
-      for (const showProp of props) {
-        this.set(showProp, false);
-      }
-
-      this.$.errorView.classList.add('show');
-      const response = e.detail.response;
-      const err = {text: [response.status, response.statusText].join(' ')};
-      if (response.status === 404) {
-        err.emoji = '¯\\_(ツ)_/¯';
-        this._lastError = err;
-      } else {
-        err.emoji = 'o_O';
-        response.text().then(text => {
-          err.moreInfo = text;
-          this._lastError = err;
-        });
-      }
-    },
-
-    _handleLocationChange(e) {
-      const hash = e.detail.hash.substring(1);
-      let pathname = e.detail.pathname;
-      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
-        pathname += '@' + hash;
-      }
-      this.set('_path', pathname);
-    },
-
-    _paramsChanged(paramsRecord) {
-      const params = paramsRecord.base;
-      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
-      if (viewsToCheck.includes(params.view)) {
-        this.set('_lastSearchPage', location.pathname);
-      }
-    },
-
-    _handleTitleChange(e) {
-      if (e.detail.title) {
-        document.title = e.detail.title + ' · Gerrit Code Review';
-      } else {
-        document.title = '';
-      }
-    },
-
-    _showKeyboardShortcuts(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.keyboardShortcuts.open();
-    },
-
-    _handleKeyboardShortcutDialogClose() {
-      this.$.keyboardShortcuts.close();
-    },
-
-    _handleAccountDetailUpdate(e) {
-      this.$.mainHeader.reload();
-      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
-        this.$$('gr-settings-view').reloadAccountDetail();
-      }
-    },
-
-    _handleRegistrationDialogClose(e) {
-      this.params.justRegistered = false;
-      this.$.registrationOverlay.close();
-    },
-
-    _computeShadowClass(isShadowDom) {
-      return isShadowDom ? 'shadow' : '';
-    },
-
-    _goToUserDashboard() {
-      Gerrit.Nav.navigateToUserDashboard();
-    },
-
-    _goToOpenedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('open');
-    },
-
-    _goToMergedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('merged');
-    },
-
-    _goToAbandonedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('abandoned');
-    },
-
-    _goToWatchedChanges() {
-      // The query is hardcoded, and doesn't respect custom menu entries
-      Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
-    },
-
-    _computePluginScreenName({plugin, screen}) {
-      return Gerrit._getPluginScreenName(plugin, screen);
-    },
-
-    _logWelcome() {
-      console.group('Runtime Info');
-      console.log('Gerrit UI (PolyGerrit)');
-      console.log(`Gerrit Server Version: ${this._version}`);
-      if (window.VERSION_INFO) {
-        console.log(`UI Version Info: ${window.VERSION_INFO}`);
-      }
-      const renderTime = new Date(window.performance.timing.loadEventStart);
-      console.log(`Document loaded at: ${renderTime}`);
-      if (this._feedbackUrl) {
-        console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-      }
-      console.groupEnd();
-    },
-
-    /**
-     * Intercept RPC log events emitted by REST API interfaces.
-     * Note: the REST API interface cannot use gr-reporting directly because
-     * that would create a cyclic dependency.
-     */
-    _handleRpcLog(e) {
-      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
-          e.detail.elapsed);
-    },
-
-    _mobileSearchToggle(e) {
-      this.mobileSearch = !this.mobileSearch;
-    },
-
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 71ceab4..9f1b7f8 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,11 +18,19 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</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.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.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-app.html">
+
+<script>
+  const link = document.createElement('link');
+  link.setAttribute('rel', 'import');
+  link.setAttribute('href', 'gr-app.html');
+  document.head.appendChild(link);
+</script>
 
 <script>void(0);</script>
 
@@ -45,12 +53,18 @@
       stub('gr-account-dropdown', {
         _getTopContent: sinon.stub(),
       });
+      stub('gr-router', {
+        start: sandbox.stub(),
+      });
       stub('gr-rest-api-interface', {
         getAccount() { return Promise.resolve({}); },
         getAccountCapabilities() { return Promise.resolve({}); },
         getConfig() {
           return Promise.resolve({
             plugin: {},
+            auth: {
+              auth_type: undefined,
+            },
           });
         },
         getPreferences() { return Promise.resolve({my: []}); },
@@ -68,21 +82,33 @@
       sandbox.restore();
     });
 
+    appElement = () => {
+      return element.$['app-element'];
+    };
+
     test('reporting', () => {
-      assert.isTrue(element.$.reporting.appStarted.calledOnce);
+      assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
+    });
+
+    test('reporting called before router start', () => {
+      const element = appElement();
+      const appStartedStub = element.$.reporting.appStarted;
+      const routerStartStub = element.$.router.start;
+      sinon.assert.callOrder(appStartedStub, routerStartStub);
     });
 
     test('passes config to gr-plugin-host', () => {
-      return element.$.restAPI.getConfig.lastCall.returnValue.then(config => {
-        assert.deepEqual(element.$.plugins.config, config);
+      const config = appElement().$.restAPI.getConfig;
+      return config.lastCall.returnValue.then(config => {
+        assert.deepEqual(appElement().$.plugins.config, config);
       });
     });
 
     test('_paramsChanged sets search page', () => {
-      element._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
-      assert.notOk(element._lastSearchPage);
-      element._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
-      assert.ok(element._lastSearchPage);
+      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
+      assert.notOk(appElement()._lastSearchPage);
+      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
+      assert.ok(appElement()._lastSearchPage);
     });
   });
 </script>
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 159f50a..2537a37 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-admin-api.html">
@@ -37,7 +39,7 @@
       let plugin;
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      Gerrit._loadPlugins([]);
       adminApi = plugin.admin();
     });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
index 208f1e8..ece8677 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-attribute-helper">
   <script src="gr-attribute-helper.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
index 86238cf..0c4149c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-attribute-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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-attribute-helper.html"/>
 
@@ -30,7 +32,6 @@
   <script>
     Polymer({
       is: 'some-element',
-      _legacyUndefinedCheck: true,
       properties: {
         fooBar: {
           type: Object,
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
index eddb52b..dd532e1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-change-metadata-api">
   <script src="gr-change-metadata-api.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
index 252e812..8b9000f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
@@ -15,8 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-dom-hooks">
   <script src="gr-dom-hooks.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 028285b..2d07382 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -54,7 +54,6 @@
   GrDomHook.prototype._createPlaceholder = function(hookName) {
     Polymer({
       is: hookName,
-      _legacyUndefinedCheck: true,
       properties: {
         plugin: Object,
         content: Object,
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index 3dde458..9e657fa 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -18,11 +18,14 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dom-hooks</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-dom-hooks.html"/>
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <script>void(0);</script>
 
@@ -116,7 +119,7 @@
         hookInternal.handleInstanceAttached(el1);
         hookInternal.handleInstanceAttached(el2);
         assert.deepEqual([el1, el2], hook.getAllAttached());
-        hookI.handleInstanceDetached(el1);
+        hookInternal.handleInstanceDetached(el1);
         assert.deepEqual([el2], hook.getAllAttached());
       });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
index 50b80d5..ab892ac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-endpoint-decorator">
@@ -23,4 +23,4 @@
     <slot></slot>
   </template>
   <script src="gr-endpoint-decorator.js"></script>
-</dom-module>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index f3d6eb4..b38107e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-endpoint-decorator',
-    _legacyUndefinedCheck: true,
 
     properties: {
       name: String,
@@ -50,9 +49,12 @@
       Gerrit._endpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
     },
 
+    /**
+     * @suppress {checkTypes}
+     */
     _import(url) {
       return new Promise((resolve, reject) => {
-        this.importHref(url, resolve, reject);
+        (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
     },
 
@@ -74,7 +76,6 @@
     },
 
     _getEndpointParams() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this).querySelectorAll('gr-endpoint-param'));
     },
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index 2e9b266..b0ad585 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-endpoint-decorator</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-endpoint-decorator.html">
 <link rel="import" href="../gr-endpoint-param/gr-endpoint-param.html">
@@ -56,7 +58,7 @@
       stub('gr-endpoint-decorator', {
         _import: sandbox.stub().returns(Promise.resolve()),
       });
-      Gerrit._resetPlugins();
+      Gerrit._testOnly_resetPlugins();
       container = fixture('basic');
       Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
       // Decoration
@@ -65,7 +67,7 @@
       replacementHook = plugin.registerCustomComponent(
           'second', 'other-module', {replace: true});
       // Mimic all plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
       flush(done);
     });
 
@@ -86,7 +88,7 @@
     test('decoration', () => {
       const element =
           container.querySelector('gr-endpoint-decorator[name="first"]');
-      const modules = Polymer.dom(element.root).children.filter(
+      const modules = Array.from(Polymer.dom(element.root).children).filter(
           element => element.nodeName === 'SOME-MODULE');
       assert.equal(modules.length, 1);
       const [module] = modules;
@@ -103,7 +105,7 @@
     test('replacement', () => {
       const element =
           container.querySelector('gr-endpoint-decorator[name="second"]');
-      const module = Polymer.dom(element.root).children.find(
+      const module = Array.from(Polymer.dom(element.root).children).find(
           element => element.nodeName === 'OTHER-MODULE');
       assert.isOk(module);
       assert.equal(module['someparam'], 'foofoo');
@@ -120,7 +122,7 @@
       flush(() => {
         const element =
             container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module = Polymer.dom(element.root).children.find(
+        const module = Array.from(Polymer.dom(element.root).children).find(
             element => element.nodeName === 'NOOB-NOOB');
         assert.isOk(module);
         done();
@@ -133,10 +135,10 @@
       flush(() => {
         const element =
             container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module1 = Polymer.dom(element.root).children.find(
+        const module1 = Array.from(Polymer.dom(element.root).children).find(
             element => element.nodeName === 'MOD-ONE');
         assert.isOk(module1);
-        const module2 = Polymer.dom(element.root).children.find(
+        const module2 = Array.from(Polymer.dom(element.root).children).find(
             element => element.nodeName === 'MOD-TWO');
         assert.isOk(module2);
         done();
@@ -150,14 +152,14 @@
       param['value'] = undefined;
       plugin.registerCustomComponent('banana', 'noob-noob');
       flush(() => {
-        let module = Polymer.dom(element.root).children.find(
+        let module = Array.from(Polymer.dom(element.root).children).find(
             element => element.nodeName === 'NOOB-NOOB');
         // Module waits for param to be defined.
         assert.isNotOk(module);
         const value = {abc: 'def'};
         param.value = value;
         flush(() => {
-          module = Polymer.dom(element.root).children.find(
+          module = Array.from(Polymer.dom(element.root).children).find(
               element => element.nodeName === 'NOOB-NOOB');
           assert.isOk(module);
           assert.strictEqual(module['someParam'], value);
@@ -175,7 +177,7 @@
       param.value = value1;
       plugin.registerCustomComponent('banana', 'noob-noob');
       flush(() => {
-        const module = Polymer.dom(element.root).children.find(
+        const module = Array.from(Polymer.dom(element.root).children).find(
             element => element.nodeName === 'NOOB-NOOB');
         assert.strictEqual(module['someParam'], value1);
         param.value = value2;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
index 9d28ac3..6a5b558 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-endpoint-param">
   <script src="gr-endpoint-param.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index e21fc72..c7a2d9a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -19,13 +19,29 @@
 
   Polymer({
     is: 'gr-endpoint-param',
-    _legacyUndefinedCheck: true,
+
     properties: {
       name: String,
       value: {
         type: Object,
         notify: true,
+        observer: '_valueChanged',
       },
     },
+
+    _valueChanged(newValue, oldValue) {
+      /* In polymer 2 the following change was made:
+      "Property change notifications (property-changed events) aren't fired when
+      the value changes as a result of a binding from the host"
+      (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
+      To workaround this problem, we fire the event from the observer.
+      In some cases this fire the event twice, but our code is
+      ready for it.
+      */
+      const detail = {
+        value: newValue,
+      };
+      this.dispatchEvent(new CustomEvent('value-changed', {detail}));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
index d34bdef..15db861 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 
 <dom-module id="gr-event-helper">
   <script src="gr-event-helper.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
index 81b59b6..845c1e1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -35,17 +35,35 @@
   };
 
   /**
+   * Alias of onClick
+   *
+   * @see onClick
+   */
+  GrEventHelper.prototype.onTap = function(callback) {
+    return this._listen(this.element, callback);
+  };
+
+  /**
    * Add a callback to element click or touch.
    * The callback may return false to prevent event bubbling.
    *
    * @param {function(Event):boolean} callback
    * @return {function()} Unsubscribe function.
    */
-  GrEventHelper.prototype.onTap = function(callback) {
+  GrEventHelper.prototype.onClick = function(callback) {
     return this._listen(this.element, callback);
   };
 
   /**
+   * Alias of captureClick
+   *
+   * @see captureClick
+   */
+  GrEventHelper.prototype.captureTap = function(callback) {
+    return this._listen(this.element.parentElement, callback, {capture: true});
+  };
+
+  /**
    * Add a callback to element click or touch ahead of normal flow.
    * Callback is installed on parent during capture phase.
    * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
@@ -54,13 +72,13 @@
    * @param {function(Event):boolean} callback
    * @return {function()} Unsubscribe function.
    */
-  GrEventHelper.prototype.captureTap = function(callback) {
+  GrEventHelper.prototype.captureClick = function(callback) {
     return this._listen(this.element.parentElement, callback, {capture: true});
   };
 
   GrEventHelper.prototype._listen = function(container, callback, opt_options) {
     const capture = opt_options && opt_options.capture;
-    const event = opt_options && opt_options.event || 'tap';
+    const event = opt_options && opt_options.event || 'click';
     const handler = e => {
       if (e.path.indexOf(this.element) !== -1) {
         let mayContinue = true;
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
index 47274f6..bd76bd4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-event-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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-event-helper.html"/>
 
@@ -30,13 +32,17 @@
   <script>
     Polymer({
       is: 'some-element',
-      _legacyUndefinedCheck: true,
+
       properties: {
         fooBar: {
           type: Object,
           notify: true,
         },
       },
+
+      behaviors: [
+        Gerrit.FireBehavior,
+      ],
     });
   </script>
 </dom-element>
@@ -67,14 +73,23 @@
       instance.onTap(() => {
         done();
       });
-      element.fire('tap');
+      MockInteractions.tap(element);
     });
 
     test('onTap() cancel', () => {
       const tapStub = sandbox.stub();
-      element.parentElement.addEventListener('tap', tapStub);
+      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
       instance.onTap(() => false);
-      element.fire('tap');
+      MockInteractions.tap(element);
+      flushAsynchronousOperations();
+      assert.isFalse(tapStub.called);
+    });
+
+    test('onClick() cancel', () => {
+      const tapStub = sandbox.stub();
+      element.parentElement.addEventListener('click', tapStub);
+      instance.onTap(() => false);
+      MockInteractions.tap(element);
       flushAsynchronousOperations();
       assert.isFalse(tapStub.called);
     });
@@ -83,14 +98,30 @@
       instance.captureTap(() => {
         done();
       });
-      element.fire('tap');
+      MockInteractions.tap(element);
+    });
+
+    test('captureClick()', done => {
+      instance.captureClick(() => {
+        done();
+      });
+      MockInteractions.tap(element);
     });
 
     test('captureTap() cancels tap()', () => {
       const tapStub = sandbox.stub();
-      element.addEventListener('tap', tapStub);
+      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
       instance.captureTap(() => false);
-      element.fire('tap');
+      MockInteractions.tap(element);
+      flushAsynchronousOperations();
+      assert.isFalse(tapStub.called);
+    });
+
+    test('captureClick() cancels click()', () => {
+      const tapStub = sandbox.stub();
+      element.addEventListener('click', tapStub);
+      instance.captureTap(() => false);
+      MockInteractions.tap(element);
       flushAsynchronousOperations();
       assert.isFalse(tapStub.called);
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
index a83b2ab..6a55349 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-external-style">
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 7924e27..e90ff30 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-external-style',
-    _legacyUndefinedCheck: true,
 
     properties: {
       name: String,
@@ -33,20 +32,31 @@
       },
     },
 
+    /**
+     * @suppress {checkTypes}
+     */
     _import(url) {
       if (this._urlsImported.includes(url)) { return Promise.resolve(); }
       this._urlsImported.push(url);
       return new Promise((resolve, reject) => {
-        this.importHref(url, resolve, reject);
+        (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
     },
 
     _applyStyle(name) {
       if (this._stylesApplied.includes(name)) { return; }
       this._stylesApplied.push(name);
+      // Hybrid custom-style syntax:
+      // https://polymer-library.polymer-project.org/2.0/docs/devguide/style-shadow-dom
       const s = document.createElement('style', 'custom-style');
       s.setAttribute('include', name);
-      Polymer.dom(this.root).appendChild(s);
+      const cs = document.createElement('custom-style');
+      cs.appendChild(s);
+      // When using Shadow DOM <custom-style> must be added to the <body>.
+      // Within <gr-external-style> itself the styles would have no effect.
+      const topEl = document.getElementsByTagName('body')[0];
+      topEl.insertBefore(cs, topEl.firstChild);
+      Polymer.updateStyles();
     },
 
     _importAndApply() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index ec2888d..9566067 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-external-style</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-external-style.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
index 8e106cc..f277899 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index 65f2207..6c66fbf 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-plugin-host',
-    _legacyUndefinedCheck: true,
 
     properties: {
       config: {
@@ -28,33 +27,28 @@
       },
     },
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
-
     _configChanged(config) {
       const plugins = config.plugin;
-      const htmlPlugins = (plugins.html_resource_paths || [])
-          .map(p => this._urlFor(p))
-          .filter(p => !Gerrit._isPluginPreloaded(p));
+      const htmlPlugins = (plugins.html_resource_paths || []);
       const jsPlugins =
-          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins)
-              .map(p => this._urlFor(p))
-              .filter(p => !Gerrit._isPluginPreloaded(p));
+          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
       const shouldLoadTheme = config.default_theme &&
             !Gerrit._isPluginPreloaded('preloaded:gerrit-theme');
-      const defaultTheme =
-            shouldLoadTheme ? this._urlFor(config.default_theme) : null;
+      const themeToLoad =
+            shouldLoadTheme ? [config.default_theme] : [];
+
+      // Theme should be loaded first if has one to have better UX
       const pluginsPending =
-          [].concat(jsPlugins, htmlPlugins, defaultTheme || []);
-      Gerrit._setPluginsPending(pluginsPending);
-      if (defaultTheme) {
-        // Make theme first to be first to load.
-        // Load sync to work around rare theme loading race condition.
-        this._importHtmlPlugins([defaultTheme], true);
+          themeToLoad.concat(jsPlugins, htmlPlugins);
+
+      const pluginOpts = {};
+
+      if (shouldLoadTheme) {
+        // Theme needs to be loaded synchronous.
+        pluginOpts[config.default_theme] = {sync: true};
       }
-      this._loadJsPlugins(jsPlugins);
-      this._importHtmlPlugins(htmlPlugins);
+
+      Gerrit._loadPlugins(pluginsPending, pluginOpts);
     },
 
     /**
@@ -67,53 +61,5 @@
         return !htmlPlugins.includes(counterpart);
       });
     },
-
-    /**
-     * @suppress {checkTypes}
-     * States that it expects no more than 3 parameters, but that's not true.
-     * @todo (beckysiegel) check Polymer annotations and submit change.
-     * @param {Array} plugins
-     * @param {boolean=} opt_sync
-     */
-    _importHtmlPlugins(plugins, opt_sync) {
-      const async = !opt_sync;
-      for (const url of plugins) {
-        // onload (second param) needs to be a function. When null or undefined
-        // were passed, plugins were not loaded correctly.
-        this.importHref(
-            this._urlFor(url), () => {},
-            Gerrit._pluginInstallError.bind(null, `${url} import error`),
-            async);
-      }
-    },
-
-    _loadJsPlugins(plugins) {
-      for (const url of plugins) {
-        this._createScriptTag(this._urlFor(url));
-      }
-    },
-
-    _createScriptTag(url) {
-      const el = document.createElement('script');
-      el.defer = true;
-      el.src = url;
-      el.onerror = Gerrit._pluginInstallError.bind(null, `${url} load error`);
-      return document.body.appendChild(el);
-    },
-
-    _urlFor(pathOrUrl) {
-      if (!pathOrUrl) {
-        return pathOrUrl;
-      }
-      if (pathOrUrl.startsWith('preloaded:') ||
-          pathOrUrl.startsWith('http')) {
-        // Plugins are loaded from another domain or preloaded.
-        return pathOrUrl;
-      }
-      if (!pathOrUrl.startsWith('/')) {
-        pathOrUrl = '/' + pathOrUrl;
-      }
-      return window.location.origin + this.getBaseUrl() + pathOrUrl;
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index 9901d9f..3a8e4d8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-plugin-host.html">
 
@@ -36,195 +38,57 @@
   suite('gr-plugin-host tests', () => {
     let element;
     let sandbox;
-    let url;
 
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       sandbox.stub(document.body, 'appendChild');
       sandbox.stub(element, 'importHref');
-      url = window.location.origin;
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('counts plugins', () => {
-      sandbox.stub(Gerrit, '_setPluginsCount');
+    test('load plugins should be called', () => {
+      sandbox.stub(Gerrit, '_loadPlugins');
       element.config = {
         plugin: {
           html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
           js_resource_paths: ['plugins/42'],
         },
       };
-      assert.isTrue(Gerrit._setPluginsCount.calledWith(3));
+      assert.isTrue(Gerrit._loadPlugins.calledOnce);
+      assert.isTrue(Gerrit._loadPlugins.calledWith([
+        'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+      ], {}));
     });
 
-    test('imports relative html plugins from config', () => {
-      sandbox.stub(Gerrit, '_pluginInstallError');
+    test('theme plugins should be loaded if enabled', () => {
+      sandbox.stub(Gerrit, '_loadPlugins');
       element.config = {
-        plugin: {html_resource_paths: ['foo/bar', 'baz']},
-      };
-      assert.equal(element.importHref.firstCall.args[0], url + '/foo/bar');
-      assert.isTrue(element.importHref.firstCall.args[3]);
-
-      assert.equal(element.importHref.secondCall.args[0], url + '/baz');
-      assert.isTrue(element.importHref.secondCall.args[3]);
-
-      assert.equal(Gerrit._pluginInstallError.callCount, 0);
-      element.importHref.firstCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 1);
-      element.importHref.secondCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 2);
-    });
-
-    test('imports relative html plugins from config with a base url', () => {
-      sandbox.stub(Gerrit, '_pluginInstallError');
-      sandbox.stub(element, 'getBaseUrl').returns('/the-base');
-      element.config = {
-        plugin: {html_resource_paths: ['foo/bar', 'baz']}};
-      assert.equal(element.importHref.firstCall.args[0],
-          url + '/the-base/foo/bar');
-      assert.isTrue(element.importHref.firstCall.args[3]);
-
-      assert.equal(element.importHref.secondCall.args[0],
-          url + '/the-base/baz');
-      assert.isTrue(element.importHref.secondCall.args[3]);
-      assert.equal(Gerrit._pluginInstallError.callCount, 0);
-      element.importHref.firstCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 1);
-      element.importHref.secondCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 2);
-    });
-
-    test('importHref is not called with null callback functions', () => {
-      const plugins = ['path/to/plugin'];
-      element._importHtmlPlugins(plugins);
-      assert.isTrue(element.importHref.calledOnce);
-      assert.isFunction(element.importHref.lastCall.args[1]);
-      assert.isFunction(element.importHref.lastCall.args[2]);
-    });
-
-    test('imports absolute html plugins from config', () => {
-      sandbox.stub(Gerrit, '_pluginInstallError');
-      element.config = {
+        default_theme: 'gerrit-theme.html',
         plugin: {
-          html_resource_paths: [
-            'http://example.com/foo/bar',
-            'https://example.com/baz',
-          ],
+          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+          js_resource_paths: ['plugins/42'],
         },
       };
-      assert.equal(element.importHref.firstCall.args[0],
-          'http://example.com/foo/bar');
-      assert.isTrue(element.importHref.firstCall.args[3]);
-
-      assert.equal(element.importHref.secondCall.args[0],
-          'https://example.com/baz');
-      assert.isTrue(element.importHref.secondCall.args[3]);
-      assert.equal(Gerrit._pluginInstallError.callCount, 0);
-      element.importHref.firstCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 1);
-      element.importHref.secondCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 2);
+      assert.isTrue(Gerrit._loadPlugins.calledOnce);
+      assert.isTrue(Gerrit._loadPlugins.calledWith([
+        'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+      ], {'gerrit-theme.html': {sync: true}}));
     });
 
-    test('adds js plugins from config to the body', () => {
-      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
-      assert.isTrue(document.body.appendChild.calledTwice);
-    });
-
-    test('imports relative js plugins from config', () => {
-      sandbox.stub(element, '_createScriptTag');
-      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
-      assert.isTrue(element._createScriptTag.calledWith(url + '/foo/bar'));
-      assert.isTrue(element._createScriptTag.calledWith(url + '/baz'));
-    });
-
-    test('imports relative html plugins from config with a base url', () => {
-      sandbox.stub(element, '_createScriptTag');
-      sandbox.stub(element, 'getBaseUrl').returns('/the-base');
-      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
-      assert.isTrue(element._createScriptTag.calledWith(
-          url + '/the-base/foo/bar'));
-      assert.isTrue(element._createScriptTag.calledWith(
-          url + '/the-base/baz'));
-    });
-
-    test('imports absolute html plugins from config', () => {
-      sandbox.stub(element, '_createScriptTag');
-      element.config = {
-        plugin: {
-          js_resource_paths: [
-            'http://example.com/foo/bar',
-            'https://example.com/baz',
-          ],
-        },
-      };
-      assert.isTrue(element._createScriptTag.calledWith(
-          'http://example.com/foo/bar'));
-      assert.isTrue(element._createScriptTag.calledWith(
-          'https://example.com/baz'));
-    });
-
-    test('default theme is loaded with html plugins', () => {
-      sandbox.stub(Gerrit, '_pluginInstallError');
-      element.config = {
-        default_theme: '/oof',
-        plugin: {
-          html_resource_paths: ['some'],
-        },
-      };
-      assert.equal(element.importHref.firstCall.args[0], url + '/oof');
-      assert.isFalse(element.importHref.firstCall.args[3]);
-
-      assert.equal(element.importHref.secondCall.args[0], url + '/some');
-      assert.isTrue(element.importHref.secondCall.args[3]);
-      assert.equal(Gerrit._pluginInstallError.callCount, 0);
-      element.importHref.firstCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 1);
-      element.importHref.secondCall.args[2]();
-      assert.equal(Gerrit._pluginInstallError.callCount, 2);
-    });
-
-    test('default theme is loaded with html plugins', () => {
-      sandbox.stub(Gerrit, '_setPluginsPending');
-      element.config = {
-        default_theme: '/oof',
-        plugin: {},
-      };
-      assert.isTrue(Gerrit._setPluginsPending.calledWith([url + '/oof']));
-    });
-
-    test('skips default theme loading if preloaded', () => {
+    test('skip theme if preloaded', () => {
       sandbox.stub(Gerrit, '_isPluginPreloaded')
           .withArgs('preloaded:gerrit-theme').returns(true);
-      sandbox.stub(Gerrit, '_setPluginsPending');
+      sandbox.stub(Gerrit, '_loadPlugins');
       element.config = {
         default_theme: '/oof',
         plugin: {},
       };
-      assert.isFalse(element.importHref.calledWith(url + '/oof'));
-    });
-
-    test('skips preloaded plugins', () => {
-      sandbox.stub(Gerrit, '_isPluginPreloaded')
-          .withArgs(url + '/plugins/foo/bar').returns(true)
-          .withArgs(url + '/plugins/42').returns(true);
-      sandbox.stub(Gerrit, '_setPluginsCount');
-      sandbox.stub(Gerrit, '_setPluginsPending');
-      sandbox.stub(element, '_createScriptTag');
-      element.config = {
-        plugin: {
-          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-          js_resource_paths: ['plugins/42'],
-        },
-      };
-      assert.isTrue(
-          Gerrit._setPluginsPending.calledWith([url + '/plugins/baz']));
-      assert.equal(element._createScriptTag.callCount, 0);
-      assert.isTrue(element.importHref.calledWith(url + '/plugins/baz'));
+      assert.isTrue(Gerrit._loadPlugins.calledOnce);
+      assert.isTrue(Gerrit._loadPlugins.calledWith([], {}));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
index ce0bf1b..402d988 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 
 <dom-module id="gr-plugin-popup">
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
index 3ef93e4..2e7a2b7 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -16,15 +16,18 @@
  */
 (function(window) {
   'use strict';
+
   Polymer({
     is: 'gr-plugin-popup',
-    _legacyUndefinedCheck: true,
+
     get opened() {
       return this.$.overlay.opened;
     },
+
     open() {
       return this.$.overlay.open();
     },
+
     close() {
       this.$.overlay.close();
     },
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
index 91386b9..1f1e81e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-popup</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-plugin-popup.html"/>
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
index 2fdf28c..26ece30 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-plugin-popup.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
index 983c795..53370e2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-popup-interface</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-popup-interface.html"/>
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
index c9486ae..593c1e0 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../admin/gr-repo-command/gr-repo-command.html">
 
 <dom-module id="gr-plugin-repo-command">
@@ -25,7 +25,6 @@
   <script>
     Polymer({
       is: 'gr-plugin-repo-command',
-      _legacyUndefinedCheck: true,
       properties: {
         title: String,
         repoName: String,
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
index 34c9797..8e6c053 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-plugin-repo-command.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index bb9ae87..0b32f8a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-repo-api.html">
@@ -44,7 +46,7 @@
       let plugin;
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      Gerrit._loadPlugins([]);
       repoApi = plugin.project();
     });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
index 7c916dc..20cc71b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../settings/gr-settings-view/gr-settings-item.html">
 <link rel="import" href="../../settings/gr-settings-view/gr-settings-menu-item.html">
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
index cabd26b..cbc2de6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-settings-api.html">
@@ -46,7 +48,7 @@
       let plugin;
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      Gerrit._loadPlugins([]);
       settingsApi = plugin.settings();
     });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
new file mode 100644
index 0000000..74b87c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
@@ -0,0 +1,18 @@
+<!--
+@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.
+-->
+
+<script src="gr-styles-api.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
new file mode 100644
index 0000000..d5647ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
@@ -0,0 +1,83 @@
+/**
+ * @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';
+
+  // Prevent redefinition.
+  if (window.GrStylesApi) { return; }
+
+  let styleObjectCount = 0;
+
+  function GrStyleObject(rulesStr) {
+    this._rulesStr = rulesStr;
+    this._className = `__pg_js_api_class_${styleObjectCount}`;
+    styleObjectCount++;
+  }
+
+  /**
+   * Creates a new unique CSS class and injects it in a root node of the element
+   * if it hasn't been added yet. A root node is an document or is the
+   * associated shadowRoot. This class can be added to any element with the same
+   * root node.
+   *
+   * @param {HTMLElement} element The element to get class name for.
+   * @return {string} Appropriate class name for the element is returned
+   */
+  GrStyleObject.prototype.getClassName = function(element) {
+    let rootNode = Polymer.Settings.useShadow
+      ? element.getRootNode() : document.body;
+    if (rootNode === document) {
+      rootNode = document.head;
+    }
+    if (!rootNode.__pg_js_api_style_tags) {
+      rootNode.__pg_js_api_style_tags = {};
+    }
+    if (!rootNode.__pg_js_api_style_tags[this._className]) {
+      const styleTag = document.createElement('style');
+      styleTag.innerHTML = `.${this._className} { ${this._rulesStr} }`;
+      rootNode.appendChild(styleTag);
+      rootNode.__pg_js_api_style_tags[this._className] = true;
+    }
+    return this._className;
+  };
+
+  /**
+   * Apply shared style to the element.
+   *
+   * @param {HTMLElement} element The element to apply style for
+   */
+  GrStyleObject.prototype.apply = function(element) {
+    element.classList.add(this.getClassName(element));
+  };
+
+
+  function GrStylesApi() {
+  }
+
+  /**
+   * Creates a new GrStyleObject with specified style properties.
+   *
+   * @param {string} String with style properties.
+   * @return {GrStyleObject}
+   */
+  GrStylesApi.prototype.css = function(ruleStr) {
+    return new GrStyleObject(ruleStr);
+  };
+
+
+  window.GrStylesApi = GrStylesApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
new file mode 100644
index 0000000..46bda6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
@@ -0,0 +1,182 @@
+<!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-admin-api</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="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-styles-api.html">
+
+<script>void(0);</script>
+
+<dom-module id="gr-style-test-element">
+  <template>
+    <div id="wrapper"></div>
+  </template>
+  <script>Polymer({is: 'gr-style-test-element'});</script>
+</dom-module>
+
+<script>
+  suite('gr-styles-api tests', () => {
+    let sandbox;
+    let stylesApi;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      Gerrit._loadPlugins([]);
+      stylesApi = plugin.styles();
+    });
+
+    teardown(() => {
+      stylesApi = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(stylesApi);
+    });
+
+    test('css', () => {
+      const styleObject = stylesApi.css('background: red');
+      assert.isDefined(styleObject);
+    });
+  });
+
+  suite('GrStyleObject tests', () => {
+    let sandbox;
+    let stylesApi;
+    let displayInlineStyle;
+    let displayNoneStyle;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      Gerrit._loadPlugins([]);
+      stylesApi = plugin.styles();
+      displayInlineStyle = stylesApi.css('display: inline');
+      displayNoneStyle = stylesApi.css('display: none');
+    });
+
+    teardown(() => {
+      displayInlineStyle = null;
+      displayNoneStyle = null;
+      stylesApi = null;
+      sandbox.restore();
+    });
+
+    function createNestedElements(parentElement) {
+      /* parentElement
+      *  |--- element1
+      *  |--- element2
+      *       |--- element3
+      **/
+      const element1 = document.createElement('div');
+      const element2 = document.createElement('div');
+      const element3 = document.createElement('div');
+      Polymer.dom(parentElement).appendChild(element1);
+      Polymer.dom(parentElement).appendChild(element2);
+      Polymer.dom(element2).appendChild(element3);
+
+      return [element1, element2, element3];
+    }
+
+
+    test('getClassName  - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testGetClassName(bodyLevelElements);
+    });
+
+    test('getClassName  - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      Polymer.dom(document.body).appendChild(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testGetClassName(contentElements);
+    });
+
+    function testGetClassName(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+
+      const className1 = displayInlineStyle.getClassName(elements[0]);
+      const className2 = displayNoneStyle.getClassName(elements[1]);
+      const className3 = displayInlineStyle.getClassName(elements[2]);
+
+      assert.notEqual(className2, className1);
+      assert.equal(className3, className1);
+
+      assertAllElementsHaveDefaultStyle(elements);
+
+      elements[0].classList.add(className1);
+      elements[1].classList.add(className2);
+      elements[2].classList.add(className1);
+
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    test('apply - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testApply(bodyLevelElements);
+    });
+
+    test('apply - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      Polymer.dom(document.body).appendChild(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testApply(contentElements);
+    });
+
+    function testApply(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+      displayInlineStyle.apply(elements[0]);
+      displayNoneStyle.apply(elements[1]);
+      displayInlineStyle.apply(elements[2]);
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+
+    function assertAllElementsHaveDefaultStyle(elements) {
+      for (const element of elements) {
+        assert.equal(getComputedStyle(element).getPropertyValue('display'),
+            'block');
+      }
+    }
+
+    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
+      for (const key in elements) {
+        if (elements.hasOwnProperty(key)) {
+          assert.equal(
+              getComputedStyle(elements[key]).getPropertyValue('display'),
+              expectedDisplayValues[key]);
+        }
+      }
+    }
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
index 496d0e7..f0eacd2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-custom-plugin-header">
   <template>
@@ -26,7 +26,7 @@
         vertical-align: middle;
       }
       .title {
-        margin-left: .25em;
+        margin-left: var(--spacing-xs);
       }
     </style>
     <span>
@@ -37,7 +37,6 @@
   <script>
     Polymer({
       is: 'gr-custom-plugin-header',
-      _legacyUndefinedCheck: true,
       properties: {
         logoUrl: String,
         title: String,
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
index b84f5b9..d6e67fe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-custom-plugin-header.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
index 8d23ea2..6332b91 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-theme-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-theme-api.html">
@@ -65,7 +67,7 @@
         stub('gr-custom-plugin-header', {
           ready() { customHeader = this; },
         });
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
       });
 
       test('sets logo and title', done => {
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index f534771..662c6f1 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -15,8 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -31,10 +33,10 @@
       gr-avatar {
         height: 120px;
         width: 120px;
-        margin-right: .15em;
+        margin-right: var(--spacing-xs);
         vertical-align: -.25em;
       }
-      .hide {
+      div section.hide {
         display: none;
       }
     </style>
@@ -79,12 +81,17 @@
         <span
             hidden$="[[!usernameMutable]]"
             class="value">
-          <input
-              is="iron-input"
-              id="usernameInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_username}}">
+            <input
+                is="iron-input"
+                id="usernameInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_username}}">
+          </iron-input>
         </span>
       </section>
       <section id="nameSection">
@@ -95,24 +102,34 @@
         <span
             hidden$="[[!nameMutable]]"
             class="value">
-          <input
-              is="iron-input"
-              id="nameInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.name}}">
+            <input
+                is="iron-input"
+                id="nameInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_account.name}}">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
-          <input
-              is="iron-input"
-              id="statusInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.status}}">
-          </span>
+            <input
+                is="iron-input"
+                id="statusInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_account.status}}">
+          </iron-input>
+        </span>
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 51a22fc..3ba3a80 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-account-info',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when account details are changed.
@@ -69,6 +68,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     observers: [
       '_nameChanged(_account.name)',
       '_statusChanged(_account.status)',
@@ -145,6 +148,14 @@
     },
 
     _computeUsernameMutable(config, username) {
+      // Polymer 2: check for undefined
+      if ([
+        config,
+        username,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       // Username may not be changed once it is set.
       return config.auth.editable_account_fields.includes('USER_NAME') &&
           !username;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index 75b9910..a35c1f0 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-info</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-account-info.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index 72ea503..852161c 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index fe36a86..41595a98 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-agreements-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _agreements: Array,
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
index 56122a9..14cf97c 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-agreements-list.html">
 
@@ -45,7 +47,7 @@
       }];
 
       stub('gr-rest-api-interface', {
-        getAccountGroups() { return Promise.resolve(agreements); },
+        getAccountAgreements() { return Promise.resolve(agreements); },
       });
 
       element = fixture('basic');
@@ -56,10 +58,10 @@
     test('renders', () => {
       const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
-      assert.equal(rows.length, 3);
+      assert.equal(rows.length, 1);
 
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[0].textContent
+      const nameCells = Array.from(rows).map(row =>
+        row.querySelectorAll('td')[0].textContent.trim()
       );
 
       assert.equal(nameCells[0], 'Agreements 1');
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index 4f69513..88a53ee 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -15,8 +15,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.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-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -56,11 +55,11 @@
           <tr>
             <td>Number</td>
             <td class="checkboxContainer"
-                on-tap="_handleCheckboxContainerTap">
+                on-click="_handleCheckboxContainerClick">
               <input
                   type="checkbox"
                   name="number"
-                  on-tap="_handleNumberCheckboxTap"
+                  on-click="_handleNumberCheckboxClick"
                   checked$="[[showNumber]]">
             </td>
           </tr>
@@ -68,11 +67,11 @@
             <tr>
               <td>[[item]]</td>
               <td class="checkboxContainer"
-                  on-tap="_handleCheckboxContainerTap">
+                  on-click="_handleCheckboxContainerClick">
                 <input
                     type="checkbox"
                     name="[[item]]"
-                    on-tap="_handleTargetTap"
+                    on-click="_handleTargetClick"
                     checked$="[[!isColumnHidden(item, displayedColumns)]]">
               </td>
             </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index d660ee5..0fef3d62 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-change-table-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       displayedColumns: {
@@ -43,7 +42,6 @@
      * @return {!Array<string>}
      */
     _getDisplayedColumns() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(Polymer.dom(this.root)
           .querySelectorAll('.checkboxContainer input:not([name=number])'))
           .filter(checkbox => checkbox.checked)
@@ -51,28 +49,28 @@
     },
 
     /**
-     * Handle a tap on a checkbox container and relay the tap to the checkbox it
+     * Handle a click on a checkbox container and relay the click to the checkbox it
      * contains.
      */
-    _handleCheckboxContainerTap(e) {
+    _handleCheckboxContainerClick(e) {
       const checkbox = Polymer.dom(e.target).querySelector('input');
       if (!checkbox) { return; }
       checkbox.click();
     },
 
     /**
-     * Handle a tap on the number checkbox and update the showNumber property
+     * Handle a click on the number checkbox and update the showNumber property
      * accordingly.
      */
-    _handleNumberCheckboxTap(e) {
+    _handleNumberCheckboxClick(e) {
       this.showNumber = Polymer.dom(e).rootTarget.checked;
     },
 
     /**
-     * Handle a tap on a displayed column checkboxes (excluding number) and
+     * Handle a click on a displayed column checkboxes (excluding number) and
      * update the displayedColumns property accordingly.
      */
-    _handleTargetTap(e) {
+    _handleTargetClick(e) {
       this.set('displayedColumns', this._getDisplayedColumns());
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index 32fab9d..29a7081 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-change-table-editor.html">
 
@@ -117,41 +119,41 @@
           columns.filter(c => c !== 'Assignee'));
     });
 
-    test('_handleCheckboxContainerTap relayes taps to checkboxes', () => {
-      sandbox.stub(element, '_handleNumberCheckboxTap');
-      sandbox.stub(element, '_handleTargetTap');
+    test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
+      sandbox.stub(element, '_handleNumberCheckboxClick');
+      sandbox.stub(element, '_handleTargetClick');
 
       MockInteractions.tap(
           element.$$('table tr:first-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
-      assert.isFalse(element._handleTargetTap.called);
+      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+      assert.isFalse(element._handleTargetClick.called);
 
       MockInteractions.tap(
           element.$$('table tr:last-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
-      assert.isTrue(element._handleTargetTap.calledOnce);
+      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+      assert.isTrue(element._handleTargetClick.calledOnce);
     });
 
-    test('_handleNumberCheckboxTap', () => {
-      sandbox.spy(element, '_handleNumberCheckboxTap');
+    test('_handleNumberCheckboxClick', () => {
+      sandbox.spy(element, '_handleNumberCheckboxClick');
 
       MockInteractions
           .tap(element.$$('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
       assert.isTrue(element.showNumber);
 
       MockInteractions
           .tap(element.$$('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxTap.calledTwice);
+      assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
       assert.isFalse(element.showNumber);
     });
 
-    test('_handleTargetTap', () => {
-      sandbox.spy(element, '_handleTargetTap');
+    test('_handleTargetClick', () => {
+      sandbox.spy(element, '_handleTargetClick');
       assert.include(element.displayedColumns, 'Assignee');
       MockInteractions
           .tap(element.$$('.checkboxContainer input[name=Assignee]'));
-      assert.isTrue(element._handleTargetTap.calledOnce);
+      assert.isTrue(element._handleTargetClick.calledOnce);
       assert.notInclude(element.displayedColumns, 'Assignee');
     });
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
index 4a939e6..c29153e 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
@@ -16,8 +16,9 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.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/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -27,17 +28,17 @@
   <template>
     <style include="shared-styles">
       h1 {
-        margin-bottom: .6em;
+        margin-bottom: var(--spacing-m);
       }
       h3 {
-        margin-bottom: .5em;
+        margin-bottom: var(--spacing-m);
       }
       .agreementsUrl {
-        border: 0.1em solid #b0bdcc;
-        margin-bottom: 1.25em;
-        margin-left: 1.25em;
-        margin-right: 1.25em;
-        padding: 0.3em;
+        border: 1px solid #b0bdcc;
+        margin-bottom: var(--spacing-xl);
+        margin-left: var(--spacing-xl);
+        margin-right: var(--spacing-xl);
+        padding: var(--spacing-s);
       }
       #claNewAgreementsLabel {
         font-weight: var(--font-weight-bold);
@@ -53,15 +54,15 @@
       }
       .alreadySubmittedText {
         color: var(--error-text-color);
-        margin: 0 2em;
-        padding: .5em;
+        margin: 0 var(--spacing-xxl);
+        padding: var(--spacing-m);
       }
       .alreadySubmittedText.hide,
       .hideAgreementsTextBox {
         display: none;
       }
       main {
-        margin: 2em auto;
+        margin: var(--spacing-xxl) auto;
         max-width: 50em;
       }
     </style>
@@ -76,7 +77,7 @@
               type="radio"
               data-name$="[[item.name]]"
               data-url$="[[item.url]]"
-              on-tap="_handleShowAgreement"
+              on-click="_handleShowAgreement"
               disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]">
           <label id="claNewAgreementsLabel">[[item.name]]</label>
         </span>
@@ -95,8 +96,14 @@
         </div>
         <div class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
           <h3 class="smallHeading">Complete the agreement:</h3>
-          <input id="input-agreements" is="iron-input" bind-value="{{_agreementsText}}" placeholder="Enter 'I agree' here" />
-          <gr-button on-tap="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
+          <iron-input bind-value="{{_agreementsText}}"
+                      placeholder="Enter 'I agree' here">
+            <input id="input-agreements"
+                   is="iron-input"
+                   bind-value="{{_agreementsText}}"
+                   placeholder="Enter 'I agree' here">
+          </iron-input>
+          <gr-button on-click="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
             Submit
           </gr-button>
         </div>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index 2a93155..cfd7b21 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-cla-view',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _groups: Object,
@@ -37,6 +36,7 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
     ],
 
     attached() {
@@ -66,7 +66,9 @@
 
     _getAgreementsUrl(configUrl) {
       let url;
-      if (!configUrl) { return ''; }
+      if (!configUrl) {
+        return '';
+      }
       if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
         url = configUrl;
       } else {
@@ -100,8 +102,8 @@
     },
 
     _createToast(message) {
-      this.dispatchEvent(new CustomEvent('show-alert',
-          {detail: {message}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'show-alert', {detail: {message}, bubbles: true, composed: true}));
     },
 
     _computeShowAgreementsClass(agreements) {
@@ -135,9 +137,13 @@
     _computeHideAgreementClass(name, config) {
       if (!config) return '';
       for (const key in config) {
-        if (!config.hasOwnProperty(key)) { continue; }
+        if (!config.hasOwnProperty(key)) {
+          continue;
+        }
         for (const prop in config[key]) {
-          if (!config[key].hasOwnProperty(prop)) { continue; }
+          if (!config[key].hasOwnProperty(prop)) {
+            continue;
+          }
           if (name === config[key].name &&
               !config[key].auto_verify_group) {
             return 'hideAgreementsTextBox';
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
index aec52cb..f1b65d9 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cla-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-cla-view.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
index b3e6990..53a30c3 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -29,40 +30,64 @@
       <section>
         <span class="title">Tab width</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.tab_size}}"
               on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.tab_size}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Columns</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.line_length}}"
               on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.line_length}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Indent unit</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.indent_unit}}"
               on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.indent_unit}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 37bce08..86350f9 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-edit-preferences',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
index 42171b7..c1c5c52 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-preferences</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-edit-preferences.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index 0a7433e..caaf18b 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -62,20 +62,28 @@
           <template is="dom-repeat" items="[[_emails]]">
             <tr>
               <td class="emailColumn">[[item.email]]</td>
-              <td class="preferredControl" on-tap="_handlePreferredControlTap">
-                <input
-                    is="iron-input"
+              <td class="preferredControl" on-click="_handlePreferredControlClick">
+                <iron-input
                     class="preferredRadio"
                     type="radio"
                     on-change="_handlePreferredChange"
                     name="preferred"
-                    value="[[item.email]]"
+                    bind-value="[[item.email]]"
                     checked$="[[item.preferred]]">
+                  <input
+                      is="iron-input"
+                      class="preferredRadio"
+                      type="radio"
+                      on-change="_handlePreferredChange"
+                      name="preferred"
+                      value="[[item.email]]"
+                      checked$="[[item.preferred]]">
+                </iron-input>
               </td>
               <td>
                 <gr-button
                     data-index$="[[index]]"
-                    on-tap="_handleDeleteButton"
+                    on-click="_handleDeleteButton"
                     disabled="[[item.preferred]]"
                     class="remove-button">Delete</gr-button>
               </td>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index 71d75cc..8490b26 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-email-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
@@ -74,7 +73,7 @@
       this.hasUnsavedChanges = true;
     },
 
-    _handlePreferredControlTap(e) {
+    _handlePreferredControlClick(e) {
       if (e.target.classList.contains('preferredControl')) {
         e.target.firstElementChild.click();
       }
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index e937f8b..8d3f2d2 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-editor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-email-editor.html">
 
@@ -49,7 +51,7 @@
 
       element = fixture('basic');
 
-      element.loadData().then(done);
+      element.loadData().then(flush(done));
     });
 
     test('renders', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
index 0c589c9..cf73d99 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
@@ -28,9 +28,6 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
-      .statusHeader {
-        width: 4em;
-      }
       .keyHeader {
         width: 9em;
       }
@@ -38,11 +35,13 @@
         width: 15em;
       }
       #viewKeyOverlay {
-        padding: 2em;
+        padding: var(--spacing-xxl);
         width: 50em;
       }
       .publicKey {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         overflow-x: scroll;
         overflow-wrap: break-word;
         width: 30em;
@@ -53,11 +52,7 @@
         right: 2em;
       }
       #existing {
-        margin-bottom: 1em;
-      }
-      #existing .commentColumn {
-        min-width: 27em;
-        width: auto;
+        margin-bottom: var(--spacing-l);
       }
     </style>
     <div class="gr-form-styles">
@@ -85,7 +80,7 @@
                 </td>
                 <td class="keyHeader">
                   <gr-button
-                      on-tap="_showKey"
+                      on-click="_showKey"
                       data-index$="[[index]]"
                       link>Click to View</gr-button>
                 </td>
@@ -100,7 +95,7 @@
                 <td>
                   <gr-button
                       data-index$="[[index]]"
-                      on-tap="_handleDeleteKey">Delete</gr-button>
+                      on-click="_handleDeleteKey">Delete</gr-button>
                 </td>
               </tr>
             </template>
@@ -119,10 +114,10 @@
           </fieldset>
           <gr-button
               class="closeButton"
-              on-tap="_closeOverlay">Close</gr-button>
+              on-click="_closeOverlay">Close</gr-button>
         </gr-overlay>
         <gr-button
-            on-tap="save"
+            on-click="save"
             disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
       </fieldset>
       <fieldset>
@@ -139,7 +134,7 @@
         <gr-button
             id="addButton"
             disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-tap="_handleAddKey">Add new GPG key</gr-button>
+            on-click="_handleAddKey">Add new GPG key</gr-button>
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
index a50509c..890061e 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-gpg-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
index aa53aca..9cfbde5f 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-gpg-editor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-gpg-editor.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index 2c7afd3..ca500c8 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index 4de24aa..d62a241 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-group-list',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _groups: Array,
@@ -40,7 +39,9 @@
     _computeGroupPath(group) {
       if (!group || !group.id) { return; }
 
-      return Gerrit.Nav.getUrlForGroup(group.id);
+      // Group ID is already encoded from the API
+      // Decode it here to match with our router encoding behavior
+      return Gerrit.Nav.getUrlForGroup(decodeURIComponent(group.id));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 3fa5a36..0422d1b 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-group-list.html">
 
@@ -71,7 +73,8 @@
     teardown(() => { sandbox.restore(); });
 
     test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+      const rows = Array.from(
+          Polymer.dom(element.root).querySelectorAll('tbody tr'));
 
       assert.equal(rows.length, 3);
 
@@ -90,21 +93,30 @@
     });
 
     test('_computeGroupPath', () => {
-      sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+      let urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
           () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
 
       let group = {
         id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
       };
-
       assert.equal(element._computeGroupPath(group),
           '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
 
       group = {
         name: 'admin',
       };
-
       assert.isUndefined(element._computeGroupPath(group));
+
+      urlStub.restore();
+
+      urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+          () => '/admin/groups/user/test');
+
+      group = {
+        id: 'user%2Ftest',
+      };
+      assert.equal(element._computeGroupPath(group),
+          '/admin/groups/user/test');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index 54cfd65..0cb9695 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
@@ -28,19 +28,23 @@
     <style include="shared-styles">
       .password {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
       }
       #generatedPasswordOverlay {
-        padding: 2em;
+        padding: var(--spacing-xxl);
         width: 50em;
       }
       #generatedPasswordDisplay {
-        margin: 1em 0;
+        margin: var(--spacing-l) 0;
       }
       #generatedPasswordDisplay .title {
         width: unset;
       }
       #generatedPasswordDisplay .value {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
       }
       #passwordWarning {
         font-style: italic;
@@ -61,7 +65,7 @@
         </section>
         <gr-button
             id="generateButton"
-            on-tap="_handleGenerateTap">Generate new password</gr-button>
+            on-click="_handleGenerateTap">Generate new password</gr-button>
       </div>
       <span hidden$="[[!_passwordUrl]]">
         <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
@@ -91,7 +95,7 @@
         <gr-button
             link
             class="closeButton"
-            on-tap="_closeOverlay">Close</gr-button>
+            on-click="_closeOverlay">Close</gr-button>
       </div>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index 99f4504..003e471 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-http-password',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _username: String,
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index ca50b2b..8924058 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-http-password.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
index 73aa65f..ee855cc 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
@@ -50,7 +50,7 @@
         display: none;
       }
       .space {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
     </style>
     <div class="gr-form-styles">
@@ -75,7 +75,7 @@
                 <td class="deleteColumn">
                   <gr-button
                       class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                      on-tap="_handleDeleteItem">
+                      on-click="_handleDeleteItem">
                     Delete
                   </gr-button>
                 </td>
@@ -84,13 +84,13 @@
           </tbody>
         </table>
       </fieldset>
-      <dom-if if="[[_showLinkAnotherIdentity]]">
+      <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
         <fieldset>
           <a href$="[[_computeLinkAnotherIdentity()]]">
             <gr-button id="linkAnotherIdentity" link>Link Another Identity</gr-button>
           </a>
         </fieldset>
-      </dom-if>
+      </template>
     </div>
     <gr-overlay id="overlay" with-backdrop>
       <gr-confirm-delete-item-dialog
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index 4560a2e..c927f1e 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -24,7 +24,6 @@
 
   Polymer({
     is: 'gr-identities',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _identities: Object,
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
index 5468d27..1277424 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-identities</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-identities.html">
 
@@ -69,7 +71,8 @@
     });
 
     test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+      const rows = Array.from(
+          Polymer.dom(element.root).querySelectorAll('tbody tr'));
 
       assert.equal(rows.length, 2);
 
@@ -82,7 +85,8 @@
     });
 
     test('renders email', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+      const rows = Array.from(
+          Polymer.dom(element.root).querySelectorAll('tbody tr'));
 
       assert.equal(rows.length, 2);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index aa42623..1485628 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -61,22 +61,22 @@
               <td class="buttonColumn">
                 <gr-button
                     link
-                    data-index="[[index]]"
-                    on-tap="_handleMoveUpButton"
+                    data-index$="[[index]]"
+                    on-click="_handleMoveUpButton"
                     class="moveUpButton">↑</gr-button>
               </td>
               <td class="buttonColumn">
                 <gr-button
                     link
-                    data-index="[[index]]"
-                    on-tap="_handleMoveDownButton"
+                    data-index$="[[index]]"
+                    on-click="_handleMoveDownButton"
                     class="moveDownButton">↓</gr-button>
               </td>
               <td>
                 <gr-button
                     link
-                    data-index="[[index]]"
-                    on-tap="_handleDeleteButton"
+                    data-index$="[[index]]"
+                    on-click="_handleDeleteButton"
                     class="remove-button">Delete</gr-button>
               </td>
             </tr>
@@ -85,19 +85,30 @@
         <tfoot>
           <tr>
             <th>
-              <input
-                  is="iron-input"
+              <iron-input
                   placeholder="New Title"
                   on-keydown="_handleInputKeydown"
                   bind-value="{{_newName}}">
+                <input
+                    is="iron-input"
+                    placeholder="New Title"
+                    on-keydown="_handleInputKeydown"
+                    bind-value="{{_newName}}">
+              </iron-input>
             </th>
             <th>
-              <input
+              <iron-input
                   class="newUrlInput"
-                  is="iron-input"
                   placeholder="New URL"
                   on-keydown="_handleInputKeydown"
                   bind-value="{{_newUrl}}">
+                <input
+                    class="newUrlInput"
+                    is="iron-input"
+                    placeholder="New URL"
+                    on-keydown="_handleInputKeydown"
+                    bind-value="{{_newUrl}}">
+              </iron-input>
             </th>
             <th></th>
             <th></th>
@@ -105,7 +116,7 @@
               <gr-button
                   link
                   disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-                  on-tap="_handleAddButton">Add</gr-button>
+                  on-click="_handleAddButton">Add</gr-button>
             </th>
           </tr>
         </tfoot>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 8587338..4f3c0c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-menu-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       menuItems: Array,
@@ -28,7 +27,7 @@
     },
 
     _handleMoveUpButton(e) {
-      const index = Polymer.dom(e).localTarget.dataIndex;
+      const index = Number(Polymer.dom(e).localTarget.dataset.index);
       if (index === 0) { return; }
       const row = this.menuItems[index];
       const prev = this.menuItems[index - 1];
@@ -36,7 +35,7 @@
     },
 
     _handleMoveDownButton(e) {
-      const index = Polymer.dom(e).localTarget.dataIndex;
+      const index = Number(Polymer.dom(e).localTarget.dataset.index);
       if (index === this.menuItems.length - 1) { return; }
       const row = this.menuItems[index];
       const next = this.menuItems[index + 1];
@@ -44,7 +43,7 @@
     },
 
     _handleDeleteButton(e) {
-      const index = Polymer.dom(e).localTarget.dataIndex;
+      const index = Number(Polymer.dom(e).localTarget.dataset.index);
       this.splice('menuItems', index, 1);
     },
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index c8a54b6..134e018 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-menu-editor.html">
 
@@ -55,7 +57,7 @@
       MockInteractions.tap(button);
     }
 
-    setup(() => {
+    setup(done => {
       element = fixture('basic');
       menu = [
         {url: '/first/url', name: 'first name', target: '_blank'},
@@ -64,6 +66,7 @@
       ];
       element.set('menuItems', menu);
       Polymer.dom.flush();
+      flush(done);
     });
 
     test('renders', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index 5f1794c..f366d2a 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -15,7 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -43,23 +45,23 @@
         display: block;
       }
       hr {
-        margin-top: 1em;
-        margin-bottom: 1em;
+        margin-top: var(--spacing-l);
+        margin-bottom: var(--spacing-l);
       }
       header {
         border-bottom: 1px solid var(--border-color);
         font-weight: var(--font-weight-bold);
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       .container {
-        padding: .5em 1.5em;
+        padding: var(--spacing-m) var(--spacing-xl);
       }
       footer {
         display: flex;
         justify-content: flex-end;
       }
       footer gr-button {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       input {
         width: 20em;
@@ -81,19 +83,27 @@
         <hr>
         <section>
           <div class="title">Full Name</div>
-          <input
-              is="iron-input"
-              id="name"
+          <iron-input
               bind-value="{{_account.name}}"
               disabled="[[_saving]]">
+            <input
+                is="iron-input"
+                id="name"
+                bind-value="{{_account.name}}"
+                disabled="[[_saving]]">
+          </iron-input>
         </section>
         <section class$="[[_computeUsernameClass(_usernameMutable)]]">
           <div class="title">Username</div>
-          <input
-              is="iron-input"
-              id="username"
+          <iron-input
               bind-value="{{_account.username}}"
               disabled="[[_saving]]">
+            <input
+                is="iron-input"
+                id="username"
+                bind-value="{{_account.username}}"
+                disabled="[[_saving]]">
+          </iron-input>
         </section>
         <section>
           <div class="title">Preferred Email</div>
@@ -109,7 +119,7 @@
         <hr>
         <p>
           More configuration options for Gerrit may be found in the
-          <a on-tap="close" href$="[[settingsUrl]]">settings</a>.
+          <a on-click="close" href$="[[settingsUrl]]">settings</a>.
         </p>
       </main>
       <footer>
@@ -117,13 +127,13 @@
             id="closeButton"
             link
             disabled="[[_saving]]"
-            on-tap="_handleClose">Close</gr-button>
+            on-click="_handleClose">Close</gr-button>
         <gr-button
             id="saveButton"
             primary
             link
             disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
-            on-tap="_handleSave">Save</gr-button>
+            on-click="_handleSave">Save</gr-button>
       </footer>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 6b4ee18..0633416 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-registration-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when account details are changed.
@@ -60,6 +59,10 @@
       _serverConfig: Object,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     hostAttributes: {
       role: 'dialog',
     },
@@ -120,6 +123,14 @@
     },
 
     _computeUsernameMutable(config, username) {
+      // Polymer 2: check for undefined
+      if ([
+        config,
+        username,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       return config.auth.editable_account_fields.includes('USER_NAME') &&
           !username;
     },
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index 93a3188..d1b5c80 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-registration-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-registration-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
index 30a3801..937ee79 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
@@ -15,14 +15,14 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-settings-item">
   <template>
     <style>
       :host {
         display: block;
-        margin-bottom: 2em;
+        margin-bottom: var(--spacing-xxl);
       }
     </style>
     <h2 id="[[anchor]]">[[title]]</h2>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
index dc1aa93..dae3b68 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-settings-item',
-    _legacyUndefinedCheck: true,
+
     properties: {
       anchor: String,
       title: String,
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
index f64d898..846f776 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
 
 <dom-module id="gr-settings-menu-item">
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
index 2a56b09..5db0031 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-settings-menu-item',
-    _legacyUndefinedCheck: true,
+
     properties: {
       href: String,
       title: String,
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index f0c4d6e..74971cf 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
+
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
@@ -49,18 +51,18 @@
       :host {
         color: var(--primary-text-color);
       }
-      #newEmailInput {
+      .newEmailInput {
         width: 20em;
       }
       #email {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       main section.darkToggle {
         display: block;
       }
       .filters p,
       .darkToggle p {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       .queryExample em {
         color: violet;
@@ -68,8 +70,8 @@
       .toggle {
         align-items: center;
         display: flex;
-        margin-bottom: 1rem;
-        margin-right: 1rem;
+        margin-bottom: var(--spacing-l);
+        margin-right: var(--spacing-l);
       }
     </style>
     <style include="gr-form-styles"></style>
@@ -132,7 +134,7 @@
               mutable="{{_accountNameMutable}}"
               has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
           <gr-button
-              on-tap="_handleSaveAccountInfo"
+              on-click="_handleSaveAccountInfo"
               disabled="[[!_accountInfoChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
@@ -278,7 +280,7 @@
           </section>
           <gr-button
               id="savePrefs"
-              on-tap="_handleSavePreferences"
+              on-click="_handleSavePreferences"
               disabled="[[!_prefsChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
@@ -292,7 +294,7 @@
               has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
           <gr-button
               id="saveDiffPrefs"
-              on-tap="_handleSaveDiffPreferences"
+              on-click="_handleSaveDiffPreferences"
               disabled$="[[!_diffPrefsChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
@@ -306,7 +308,7 @@
               has-unsaved-changes="{{_editPrefsChanged}}"></gr-edit-preferences>
           <gr-button
               id="saveEditPrefs"
-              on-tap="_handleSaveEditPreferences"
+              on-click="_handleSaveEditPreferences"
               disabled$="[[!_editPrefsChanged]]">Save changes</gr-button>
         </fieldset>
         <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
@@ -315,12 +317,12 @@
               menu-items="{{_localMenu}}"></gr-menu-editor>
           <gr-button
               id="saveMenu"
-              on-tap="_handleSaveMenu"
+              on-click="_handleSaveMenu"
               disabled="[[!_menuChanged]]">Save changes</gr-button>
           <gr-button
               id="resetMenu"
               link
-              on-tap="_handleResetMenuButton">Reset</gr-button>
+              on-click="_handleResetMenuButton">Reset</gr-button>
         </fieldset>
         <h2 id="ChangeTableColumns"
             class$="[[_computeHeaderClass(_changeTableChanged)]]">
@@ -333,7 +335,7 @@
           </gr-change-table-editor>
           <gr-button
               id="saveChangeTable"
-              on-tap="_handleSaveChangeTable"
+              on-click="_handleSaveChangeTable"
               disabled="[[!_changeTableChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
@@ -346,7 +348,7 @@
               has-unsaved-changes="{{_watchedProjectsChanged}}"
               id="watchedProjectsEditor"></gr-watched-projects-editor>
           <gr-button
-              on-tap="_handleSaveWatchedProjects"
+              on-click="_handleSaveWatchedProjects"
               disabled$="[[!_watchedProjectsChanged]]"
               id="_handleSaveWatchedProjects">Save changes</gr-button>
         </fieldset>
@@ -360,21 +362,29 @@
               id="emailEditor"
               has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
           <gr-button
-              on-tap="_handleSaveEmails"
+              on-click="_handleSaveEmails"
               disabled$="[[!_emailsChanged]]">Save changes</gr-button>
         </fieldset>
         <fieldset id="newEmail">
           <section>
             <span class="title">New email address</span>
             <span class="value">
-              <input
-                  id="newEmailInput"
+              <iron-input
+                  class="newEmailInput"
                   bind-value="{{_newEmail}}"
-                  is="iron-input"
                   type="text"
                   disabled="[[_addingEmail]]"
                   on-keydown="_handleNewEmailKeydown"
                   placeholder="email@example.com">
+                <input
+                    class="newEmailInput"
+                    bind-value="{{_newEmail}}"
+                    is="iron-input"
+                    type="text"
+                    disabled="[[_addingEmail]]"
+                    on-keydown="_handleNewEmailKeydown"
+                    placeholder="email@example.com">
+              </iron-input>
             </span>
           </section>
           <section
@@ -387,7 +397,7 @@
           </section>
           <gr-button
               disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-              on-tap="_handleAddEmailButton">Send verification</gr-button>
+              on-click="_handleAddEmailButton">Send verification</gr-button>
         </fieldset>
         <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
           <div>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index a66c38d..714faab 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -47,7 +47,6 @@
 
   Polymer({
     is: 'gr-settings-view',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -149,6 +148,7 @@
     behaviors: [
       Gerrit.DocsUrlBehavior,
       Gerrit.ChangeTableBehavior,
+      Gerrit.FireBehavior,
     ],
 
     observers: [
@@ -392,7 +392,7 @@
     },
 
     _isNewEmailValid(newEmail) {
-      return newEmail.includes('@');
+      return newEmail && newEmail.includes('@');
     },
 
     _computeAddEmailButtonEnabled(newEmail, addingEmail) {
@@ -435,6 +435,7 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message: RELOAD_MESSAGE},
         bubbles: true,
+        composed: true,
       }));
       this.async(() => {
         window.location.reload();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 506c6af..6dcf124 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-settings-view.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 57458f8..2a27194 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
@@ -35,11 +35,13 @@
         width: 7.5em;
       }
       #viewKeyOverlay {
-        padding: 2em;
+        padding: var(--spacing-xxl);
         width: 50em;
       }
       .publicKey {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         overflow-x: scroll;
         overflow-wrap: break-word;
         width: 30em;
@@ -50,7 +52,7 @@
         right: 2em;
       }
       #existing {
-        margin-bottom: 1em;
+        margin-bottom: var(--spacing-l);
       }
       #existing .commentColumn {
         min-width: 27em;
@@ -77,7 +79,7 @@
                 <td>
                   <gr-button
                       link
-                      on-tap="_showKey"
+                      on-click="_showKey"
                       data-index$="[[index]]"
                       link>Click to View</gr-button>
                 </td>
@@ -93,7 +95,7 @@
                   <gr-button
                       link
                       data-index$="[[index]]"
-                      on-tap="_handleDeleteKey">Delete</gr-button>
+                      on-click="_handleDeleteKey">Delete</gr-button>
                 </td>
               </tr>
             </template>
@@ -116,10 +118,10 @@
           </fieldset>
           <gr-button
               class="closeButton"
-              on-tap="_closeOverlay">Close</gr-button>
+              on-click="_closeOverlay">Close</gr-button>
         </gr-overlay>
         <gr-button
-            on-tap="save"
+            on-click="save"
             disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
       </fieldset>
       <fieldset>
@@ -137,7 +139,7 @@
             id="addButton"
             link
             disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-tap="_handleAddKey">Add new SSH key</gr-button>
+            on-click="_handleAddKey">Add new SSH key</gr-button>
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 4c423e8..874173a 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-ssh-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index 8ed0730..d313f5a 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ssh-editor</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-ssh-editor.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 85fe368..360ea2d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -14,7 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -27,7 +28,7 @@
     <style include="gr-form-styles">
       #watchedProjects .notifType {
         text-align: center;
-        padding: 0 0.4em;
+        padding: 0 var(--spacing-s);
       }
       .notifControl {
         cursor: pointer;
@@ -39,7 +40,7 @@
       .projectFilter {
         color: var(--deemphasized-text-color);
         font-style: italic;
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       .newFilterInput {
         width: 100%;
@@ -73,7 +74,7 @@
                   is="dom-repeat"
                   items="[[_getTypes()]]"
                   as="type">
-                <td class="notifControl" on-tap="_handleNotifCellTap">
+                <td class="notifControl" on-click="_handleNotifCellClick">
                   <input
                       type="checkbox"
                       data-index$="[[projectIndex]]"
@@ -86,7 +87,7 @@
                 <gr-button
                     link
                     data-index$="[[projectIndex]]"
-                    on-tap="_handleRemoveProject">Delete</gr-button>
+                    on-click="_handleRemoveProject">Delete</gr-button>
               </td>
             </tr>
           </template>
@@ -103,14 +104,18 @@
                   placeholder="Repo"></gr-autocomplete>
             </th>
             <th colspan$="[[_getTypeCount()]]">
-              <input
-                  id="newFilter"
+              <iron-input
                   class="newFilterInput"
-                  is="iron-input"
                   placeholder="branch:name, or other search expression">
+                <input
+                    id="newFilter"
+                    class="newFilterInput"
+                    is="iron-input"
+                    placeholder="branch:name, or other search expression">
+              </iron-input>
             </th>
             <th>
-              <gr-button link on-tap="_handleAddProject">Add</gr-button>
+              <gr-button link on-click="_handleAddProject">Add</gr-button>
             </th>
           </tr>
         </tfoot>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index bd18456..a40094d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -27,7 +27,6 @@
 
   Polymer({
     is: 'gr-watched-projects-editor',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
@@ -170,7 +169,7 @@
       this.hasUnsavedChanges = true;
     },
 
-    _handleNotifCellTap(e) {
+    _handleNotifCellClick(e) {
       const checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) { checkbox.click(); }
     },
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 9022bcc..7a238ec 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-watched-projects-editor.html">
 
@@ -187,6 +189,7 @@
       element.$.newProject.value = {id: 'project b'};
       element.$.newProject.setText('project b');
       element.$.newFilter.bindValue = 'filter 1';
+      element.$.newFilter.value = 'filter 1';
 
       element._handleAddProject();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 543ed85..5a095a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../gr-account-link/gr-account-link.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-icons/gr-icons.html">
@@ -34,28 +35,34 @@
         background: var(--chip-background-color);
         border-radius: .75em;
         display: inline-flex;
-        padding: 0 .5em;
+        padding: 0 var(--spacing-m);
       }
       :host([show-avatar]) .container {
         padding-left: 0;
       }
+      gr-button.remove {
+        --gr-remove-button-style: {
+          border: 0;
+          color: var(--deemphasized-text-color);
+          font-weight: normal;
+          height: .6em;
+          line-height: 10px;
+          margin-left: var(--spacing-xs);
+          padding: 0;
+          text-decoration: none;
+        }
+      }
+
       gr-button.remove:hover,
       gr-button.remove:focus {
         --gr-button: {
+          @apply --gr-remove-button-style;
           color: #333;
         }
       }
       gr-button.remove {
         --gr-button: {
-          border: 0;
-          color: var(--deemphasized-text-color);
-          font-size: 1.7rem;
-          font-weight: normal;
-          height: .6em;
-          line-height: .6;
-          margin-left: .15em;
-          padding: 0;
-          text-decoration: none;
+          @apply --gr-remove-button-style;
         }
       }
       :host:focus {
@@ -93,7 +100,7 @@
           tabindex="-1"
           aria-label="Remove"
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-tap="_handleRemoveTap">
+          on-click="_handleRemoveTap">
         <iron-icon icon="gr-icons:close"></iron-icon>
       </gr-button>
     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 827e33b..10c876d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -20,7 +20,6 @@
 
   Polymer({
     is: 'gr-account-chip',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired to indicate a key was pressed while this chip was focused.
@@ -57,6 +56,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     ready() {
       this._getHasAvatars().then(hasAvatars => {
         this.showAvatar = hasAvatars;
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 74%
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 582c83b..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,11 +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="/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>
@@ -35,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..b2e0973
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
@@ -0,0 +1,101 @@
+/**
+ * @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',
+
+    /**
+     * 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..6896af9
--- /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 bdf37bf..7ed7962 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,9 +15,9 @@
 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="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-avatar/gr-avatar.html">
 <link rel="import" href="../gr-limited-text/gr-limited-text.html">
@@ -35,7 +35,7 @@
       gr-avatar {
         height: 1.3em;
         width: 1.3em;
-        margin-right: .15em;
+        margin-right: var(--spacing-xs);
         vertical-align: -.25em;
       }
       .text {
@@ -64,7 +64,11 @@
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text limit="10" text="[[account.status]]"></gr-limited-text>)
+          (<gr-limited-text
+            disable-tooltip="true"
+            limit="[[_computeStatusTextLength(account, _serverConfig)]]"
+            text="[[account.status]]">
+          </gr-limited-text>)
         </template>
       </span>
     </span>
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 7983fad..418d2ea 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
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-account-label',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -52,7 +51,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
       Gerrit.TooltipBehavior,
     ],
 
@@ -66,18 +65,38 @@
       return this.getUserName(config, account, false);
     },
 
+    _computeStatusTextLength(account, config) {
+      // 35 as the max length of the name + status
+      return Math.max(10, 35 - this._computeName(account, config).length);
+    },
+
     _computeAccountTitle(account, tooltip) {
+      // Polymer 2: check for undefined
+      if ([
+        account,
+        tooltip,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (!account) { return; }
       let result = '';
       if (this._computeName(account, this._serverConfig)) {
         result += this._computeName(account, this._serverConfig);
       }
       if (account.email) {
-        result += ' <' + account.email + '>';
+        result += ` <${account.email}>`;
       }
       if (this.additionalText) {
-        return result + ' ' + this.additionalText;
+        result += ` ${this.additionalText}`;
       }
+
+      // Show status in the label tooltip instead of
+      // in a separate tooltip on status
+      if (account.status) {
+        result += ` (${account.status})`;
+      }
+
       return result;
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index cd8e194..45545fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-label</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -66,32 +68,35 @@
           {
             name: 'Andrew Bonventre',
             email: 'andybons+gerrit@gmail.com',
-          }),
+          }, /* additionalText= */ ''),
       'Andrew Bonventre <andybons+gerrit@gmail.com>');
 
       assert.equal(element._computeAccountTitle(
-          {name: 'Andrew Bonventre'}),
+          {name: 'Andrew Bonventre'}, /* additionalText= */ ''),
       'Andrew Bonventre');
 
       assert.equal(element._computeAccountTitle(
           {
             email: 'andybons+gerrit@gmail.com',
-          }),
+          }, /* additionalText= */ ''),
       'Anonymous <andybons+gerrit@gmail.com>');
 
       assert.equal(element._computeShowEmailClass(
           {
             name: 'Andrew Bonventre',
             email: 'andybons+gerrit@gmail.com',
-          }), '');
+          }, /* additionalText= */ ''), '');
 
       assert.equal(element._computeShowEmailClass(
           {
             email: 'andybons+gerrit@gmail.com',
-          }), 'showEmail');
+          }, /* additionalText= */ ''), 'showEmail');
 
-      assert.equal(element._computeShowEmailClass({name: 'Andrew Bonventre'}),
-          '');
+      assert.equal(element._computeShowEmailClass(
+          {name: 'Andrew Bonventre'},
+          /* additionalText= */ ''
+      ),
+      '');
 
       assert.equal(element._computeShowEmailClass(undefined), '');
 
@@ -134,5 +139,44 @@
             'TestAnon');
       });
     });
+
+    suite('status in tooltip', () => {
+      setup(() => {
+        element = fixture('basic');
+        element.account = {
+          name: 'test',
+          email: 'test@google.com',
+          status: 'OOO until Aug 10th',
+        };
+        element._config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward',
+          },
+        };
+      });
+
+      test('tooltip should contain status text', () => {
+        assert.deepEqual(element.title,
+            'test <test@google.com> (OOO until Aug 10th)');
+      });
+
+      test('status text should not have tooltip', () => {
+        flushAsynchronousOperations();
+        assert.deepEqual(element.$$('gr-limited-text').title, '');
+      });
+
+      test('status text should honor the name length and total length', () => {
+        assert.deepEqual(
+            element._computeStatusTextLength(element.account, element._config),
+            31
+        );
+        assert.deepEqual(
+            element._computeStatusTextLength({
+              name: 'a very long long long long name',
+            }, element._config),
+            10
+        );
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index 34b0de6..d3575b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../gr-account-label/gr-account-label.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 03967f1..faaf9c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-account-link',
-    _legacyUndefinedCheck: true,
 
     properties: {
       additionalText: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 6d1831e..134c579 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-link</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-account-link.html">
 
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 83%
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 1bfc5eb..2ce608be 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
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.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">
 
@@ -25,7 +26,7 @@
     <style include="shared-styles">
       gr-account-chip {
         display: inline-block;
-        margin: .2em .2em .2em 0;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
       }
       gr-account-entry {
         display: flex;
@@ -55,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>
@@ -66,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 76%
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 7cdffc8..de66c50 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
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-account-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when user inputs an invalid email address.
@@ -38,6 +37,20 @@
       change: Object,
       filter: Function,
       placeholder: String,
+      disabled: {
+        type: Function,
+        value: false,
+      },
+
+      /**
+       * Returns suggestions and convert them to list item
+       *
+       * @type {Gerrit.GrSuggestionsProvider}
+       */
+      suggestionsProvider: {
+        type: Object,
+      },
+
       /**
        * Needed for template checking since value is initially set to null.
        *
@@ -51,21 +64,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.
        */
@@ -83,14 +81,29 @@
         type: Number,
         value: 0,
       },
+
+      /** Returns suggestion items
+       *
+       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
+       */
+      _querySuggestions: {
+        type: Function,
+        value() {
+          return this._getSuggestions.bind(this);
+        },
+      },
     },
 
+    behaviors: [
+      // Used in the tests for gr-account-list and other elements tests.
+      Gerrit.FireBehavior,
+    ],
+
     listeners: {
       remove: '_handleRemove',
     },
 
     get accountChips() {
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-account-chip'));
     },
@@ -99,36 +112,54 @@
       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.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+          this.$.entry.setText(item);
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: VALID_EMAIL_ALERT},
+            bubbles: true,
+            composed: true,
+          }));
           return false;
         } else {
-          const account = {email: reviewer, _pendingAdd: true};
+          const account = {email: item, _pendingAdd: true};
           this.push('accounts', account);
         }
       }
@@ -166,8 +197,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)) {
@@ -186,7 +217,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];
@@ -203,8 +236,13 @@
       console.warn('received remove event for missing account', toRemove);
     },
 
+    _getNativeInput(paperInput) {
+      // In Polymer 2 inputElement isn't nativeInput anymore
+      return paperInput.$.nativeInput || paperInput.inputElement;
+    },
+
     _handleInputKeydown(e) {
-      const input = e.detail.input.inputElement;
+      const input = this._getNativeInput(e.detail.input);
       if (input.selectionStart !== input.selectionEnd ||
           input.selectionStart !== 0) {
         return;
@@ -270,7 +308,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 65%
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 544238b..f931a69 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-account-list.html">
 
@@ -33,6 +35,16 @@
 </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() {
@@ -49,10 +61,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');
@@ -60,14 +73,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(() => {
@@ -107,7 +122,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);
@@ -115,7 +130,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();
@@ -145,6 +160,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), '');
@@ -161,18 +222,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', () => {
@@ -291,13 +352,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;
       });
 
@@ -330,44 +418,51 @@
     });
 
     suite('keyboard interactions', () => {
-      test('backspace at text input start removes last account', () => {
+      test('backspace at text input start removes last account', done => {
         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
-        // on input field update
-        assert.equal(input.$.input.inputElement.selectionStart, 0);
-        input.text = 'test';
-        MockInteractions.focus(input.$.input);
-        flushAsynchronousOperations();
-        assert.equal(element.accounts.length, 2);
-        MockInteractions.pressAndReleaseKeyOn(
-            input.$.input.inputElement, 8); // Backspace
-        assert.equal(element.accounts.length, 2);
-        input.text = '';
-        MockInteractions.pressAndReleaseKeyOn(
-            input.$.input.inputElement, 8); // Backspace
-        assert.equal(element.accounts.length, 1);
+        flush(() => {
+          // Next line is a workaround for Firefix not moving cursor
+          // on input field update
+          assert.equal(
+              element._getNativeInput(input.$.input).selectionStart, 0);
+          input.text = 'test';
+          MockInteractions.focus(input.$.input);
+          flushAsynchronousOperations();
+          assert.equal(element.accounts.length, 2);
+          MockInteractions.pressAndReleaseKeyOn(
+              element._getNativeInput(input.$.input), 8); // Backspace
+          assert.equal(element.accounts.length, 2);
+          input.text = '';
+          MockInteractions.pressAndReleaseKeyOn(
+              element._getNativeInput(input.$.input), 8); // Backspace
+          flushAsynchronousOperations();
+          assert.equal(element.accounts.length, 1);
+          done();
+        });
       });
 
-      test('arrow key navigation', () => {
+      test('arrow key navigation', done => {
         const input = element.$.entry.$.input;
         input.text = '';
         element.accounts = [makeAccount(), makeAccount()];
-        MockInteractions.focus(input.$.input);
-        flushAsynchronousOperations();
-        const chips = element.accountChips;
-        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
-        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
-        assert.isTrue(chipsOneSpy.called);
-        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
-        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
-        assert.isTrue(chipsZeroSpy.called);
-        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
-        assert.isTrue(chipsZeroSpy.calledOnce);
-        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
-        assert.isTrue(chipsOneSpy.calledTwice);
+        flush(() => {
+          MockInteractions.focus(input.$.input);
+          flushAsynchronousOperations();
+          const chips = element.accountChips;
+          const chipsOneSpy = sandbox.spy(chips[1], 'focus');
+          MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+          assert.isTrue(chipsOneSpy.called);
+          const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+          MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+          assert.isTrue(chipsZeroSpy.called);
+          MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+          assert.isTrue(chipsZeroSpy.calledOnce);
+          MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+          assert.isTrue(chipsOneSpy.calledTwice);
+          done();
+        });
       });
 
       test('delete', done => {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index b00fded..b0018df 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -31,11 +31,10 @@
       :host([toast]) {
         background-color: var(--tooltip-background-color);
         bottom: 1.25rem;
-        border-radius: 3px;
+        border-radius: var(--border-radius);
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         color: var(--view-background-color);
         left: 1.25rem;
-        padding: 1em 1.5em;
         position: fixed;
         transform: translateY(5rem);
         transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
@@ -44,6 +43,17 @@
       :host([shown]) {
         transform: translateY(0);
       }
+      /**
+       * NOTE: To avoid style being overwritten by outside of the shadow DOM
+       * (as outside styles always win), .content-wrapper is introduced as a
+       * wrapper around main content to have better encapsulation, styles that
+       * may be affected by outside should be defined on it.
+       * In this case, `padding:0px` is defined in main.css for all elements
+       * with the universal selector: *.
+       */
+      .content-wrapper {
+        padding: var(--spacing-l) var(--spacing-xl);
+      }
       .text {
         color: var(--tooltip-text-color);
         display: inline-block;
@@ -55,19 +65,21 @@
       .action {
         color: var(--link-color);
         font-weight: var(--font-weight-bold);
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
         text-decoration: none;
         --gr-button: {
           padding: 0;
         }
       }
     </style>
-    <span class="text">[[text]]</span>
-    <gr-button
-        link
-        class="action"
-        hidden$="[[_hideActionButton]]"
-        on-tap="_handleActionTap">[[actionText]]</gr-button>
+    <div class="content-wrapper">
+      <span class="text">[[text]]</span>
+      <gr-button
+          link
+          class="action"
+          hidden$="[[_hideActionButton]]"
+          on-click="_handleActionTap">[[actionText]]</gr-button>
+    </div>
   </template>
   <script src="gr-alert.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index ec7b6eb..e7c8b2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-alert',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the action button is pressed.
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 095e640..2338d55 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-alert</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-alert.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index 7b82635..9208068 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-fit-behavior/iron-fit-behavior.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <script src="../../../scripts/rootElement.js"></script>
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -40,7 +42,7 @@
         cursor: pointer;
         display: flex;
         justify-content: space-between;
-        padding: .5em .75em;
+        padding: var(--spacing-m) var(--spacing-l);
       }
       li:last-of-type {
         border: none;
@@ -67,7 +69,7 @@
       }
       .label {
         color: var(--deemphasized-text-color);
-        padding-left: 1em;
+        padding-left: var(--spacing-l);
       }
       .hide {
         display: none;
@@ -86,7 +88,7 @@
               aria-label$="[[item.name]]"
               class="autocompleteOption"
               role="option"
-              on-tap="_handleTapItem">
+              on-click="_handleClickItem">
             <span>[[item.text]]</span>
             <span class$="label [[_computeLabelClass(item)]]">[[item.label]]</span>
           </li>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 1af629d2..b8c76ff 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-autocomplete-dropdown',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the dropdown is closed.
@@ -53,13 +52,11 @@
         value: () => [],
         observer: '_resetCursorStops',
       },
-      _suggestionEls: {
-        type: Array,
-        observer: '_resetCursorIndex',
-      },
+      _suggestionEls: Array,
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Polymer.IronFitBehavior,
     ],
@@ -78,9 +75,9 @@
 
     open() {
       this.isHidden = false;
-      this.refit();
       this._resetCursorStops();
-      this._resetCursorIndex();
+      // Refit should run after we call Polymer.flush inside _resetCursorStops
+      this.refit();
     },
 
     getCurrentText() {
@@ -138,7 +135,7 @@
       this.close();
     },
 
-    _handleTapItem(e) {
+    _handleClickItem(e) {
       e.preventDefault();
       e.stopPropagation();
       let selected = e.target;
@@ -147,7 +144,7 @@
         selected = selected.parentElement;
       }
       this.fire('item-selected', {
-        trigger: 'tap',
+        trigger: 'click',
         selected,
       });
     },
@@ -162,10 +159,12 @@
 
     _resetCursorStops() {
       if (this.suggestions.length > 0) {
-        Polymer.dom.flush();
-        // Polymer2: querySelectorAll returns NodeList instead of Array.
-        this._suggestionEls = Array.from(
-            this.$.suggestions.querySelectorAll('li'));
+        if (!this.isHidden) {
+          Polymer.dom.flush();
+          this._suggestionEls = Array.from(
+              this.$.suggestions.querySelectorAll('li'));
+          this._resetCursorIndex();
+        }
       } else {
         this._suggestionEls = [];
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
index d4d54ff..a7b59d7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-autocomplete-dropdown</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-autocomplete-dropdown.html">
 
@@ -126,7 +128,7 @@
       MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
       flushAsynchronousOperations();
       assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'tap',
+        trigger: 'click',
         selected: element.$.suggestions.querySelectorAll('li')[1],
       });
     });
@@ -139,7 +141,7 @@
           .lastElementChild);
       flushAsynchronousOperations();
       assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'tap',
+        trigger: 'click',
         selected: element.$.suggestions.querySelectorAll('li')[0],
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index a878174..c9d12ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -14,8 +14,9 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-input/paper-input.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
@@ -32,21 +33,23 @@
         display: inline-block;
       }
       iron-icon {
-        margin: 0 .25em;
+        margin: 0 var(--spacing-xs);
+        vertical-align: top;
       }
       paper-input:not(.borderless) {
         border: 1px solid var(--border-color);
       }
       paper-input {
-        height: 100%;
+        height: var(--line-height-normal);
         width: 100%;
         @apply --gr-autocomplete;
         --paper-input-container: {
           padding: 0;
-        }
+        };
         --paper-input-container-input: {
           font-size: var(--font-size-normal);
-        }
+          line-height: var(--line-height-normal);
+        };
         --paper-input-container-underline: {
           display: none;
         };
@@ -60,7 +63,7 @@
       paper-input.warnUncommitted {
         --paper-input-container-input: {
           color: var(--error-text-color);
-          font-size: var(--font-size-normal);
+          font-size: inherit;
         }
       }
     </style>
@@ -75,14 +78,14 @@
         on-focus="_onInputFocus"
         on-blur="_onInputBlur"
         autocomplete="off">
-      <!-- slot is for future use (2.x) while prefix attribute is for 1.x
-        (current) -->
-      <iron-icon
+
+      <!-- prefix as attribute is required to for polymer 1 -->
+      <div slot="prefix" prefix>
+        <iron-icon
           icon="gr-icons:search"
-          slot="prefix"
-          prefix
           class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]">
-      </iron-icon>
+        </iron-icon>
+      </div>
     </paper-input>
     <gr-autocomplete-dropdown
         vertical-align="top"
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 9d16b9c..ee087cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-autocomplete',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a value is chosen.
@@ -168,6 +167,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -176,12 +176,17 @@
       '_updateSuggestions(text, threshold, noDebounce)',
     ],
 
+    get _nativeInput() {
+      // In Polymer 2 inputElement isn't nativeInput anymore
+      return this.$.input.$.nativeInput || this.$.input.inputElement;
+    },
+
     attached() {
-      this.listen(document.body, 'tap', '_handleBodyTap');
+      this.listen(document.body, 'click', '_handleBodyClick');
     },
 
     detached() {
-      this.unlisten(document.body, 'tap', '_handleBodyTap');
+      this.unlisten(document.body, 'click', '_handleBodyClick');
       this.cancelDebouncer('update-suggestions');
     },
 
@@ -190,11 +195,11 @@
     },
 
     focus() {
-      this.$.input.focus();
+      this._nativeInput.focus();
     },
 
     selectAll() {
-      const nativeInputElement = this.$.input.inputElement;
+      const nativeInputElement = this._nativeInput;
       if (!this.$.input.value) { return; }
       nativeInputElement.setSelectionRange(0, this.$.input.value.length);
     },
@@ -205,7 +210,7 @@
 
     _handleItemSelect(e) {
       // Let _handleKeydown deal with keyboard interaction.
-      if (e.detail.trigger !== 'tap') { return; }
+      if (e.detail.trigger !== 'click') { return; }
       this._selected = e.detail.selected;
       this._commit();
     },
@@ -242,8 +247,13 @@
     },
 
     _updateSuggestions(text, threshold, noDebounce) {
+      // Polymer 2: check for undefined
+      if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (this._disableSuggestions) { return; }
-      if (text === undefined || text.length < threshold) {
+      if (text.length < threshold) {
         this._suggestions = [];
         this.value = '';
         return;
@@ -320,7 +330,7 @@
         default:
           // For any normal keypress, return focus to the input to allow for
           // unbroken user input.
-          this.$.input.inputElement.focus();
+          this.focus();
 
           // Since this has been a normal keypress, the suggestions will have
           // been based on a previous input. Clear them. This prevents an
@@ -365,7 +375,7 @@
       }
     },
 
-    _handleBodyTap(e) {
+    _handleBodyClick(e) {
       const eventPath = Polymer.dom(e).path;
       for (let i = 0; i < eventPath.length; i++) {
         if (eventPath[i] === this) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 1a76f98..ea1fd50 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-autocomplete.html">
 
@@ -80,16 +82,19 @@
       });
     });
 
-    test('selectAll', () => {
-      const nativeInput = element.$.input.inputElement;
-      const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
+    test('selectAll', done => {
+      flush(() => {
+        const nativeInput = element._nativeInput;
+        const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
 
-      element.selectAll();
-      assert.isFalse(selectionStub.called);
+        element.selectAll();
+        assert.isFalse(selectionStub.called);
 
-      element.$.input.value = 'test';
-      element.selectAll();
-      assert.isTrue(selectionStub.called);
+        element.$.input.value = 'test';
+        element.selectAll();
+        assert.isTrue(selectionStub.called);
+        done();
+      });
     });
 
     test('esc key behavior', done => {
@@ -318,12 +323,15 @@
       });
     });
 
-    test('search icon shows with showSearchIcon property', () => {
-      assert.equal(getComputedStyle(element.$$('iron-icon')).display,
-          'none');
-      element.showSearchIcon = true;
-      assert.notEqual(getComputedStyle(element.$$('iron-icon')).display,
-          'none');
+    test('search icon shows with showSearchIcon property', done => {
+      flush(() => {
+        assert.equal(getComputedStyle(element.$$('iron-icon')).display,
+            'none');
+        element.showSearchIcon = true;
+        assert.notEqual(getComputedStyle(element.$$('iron-icon')).display,
+            'none');
+        done();
+      });
     });
 
     test('vertical offset overridden by param if it exists', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
index bc63acf..1daffa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -28,7 +28,7 @@
         display: inline-block;
         border-radius: 50%;
         background-size: cover;
-        background-color: var(--background-color, #f1f2f3);
+        background-color: var(--avatar-background-color, #f1f2f3);
       }
     </style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 2435e58..bf56382 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-avatar',
-    _legacyUndefinedCheck: true,
 
     properties: {
       account: {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index 63718c5..de05a5c 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-avatar</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-avatar.html">
 
@@ -115,7 +117,7 @@
       assert.strictEqual(element.style.backgroundImage, '');
 
       // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
 
       Promise.all([
         element.$.restAPI.getConfig(),
@@ -127,16 +129,33 @@
             element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
       });
     });
+  });
+
+  suite('plugin has avatars', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      stub('gr-avatar', {
+        _getConfig: () => {
+          return Promise.resolve({plugin: {has_avatars: true}});
+        },
+      });
+
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
 
     test('dom for non available account', () => {
       assert.isFalse(element.hasAttribute('hidden'));
 
-      sandbox.stub(element, '_getConfig', () => {
-        return Promise.resolve({plugin: {has_avatars: true}});
-      });
-
       // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
 
       return Promise.all([
         element.$.restAPI.getConfig(),
@@ -147,45 +166,45 @@
         assert.strictEqual(element.style.backgroundImage, '');
       });
     });
+  });
 
-    test('avatar config not set and account not set', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
+  suite('config not set', () => {
+    let element;
+    let sandbox;
 
-      sandbox.stub(element, '_getConfig', () => {
-        return Promise.resolve({});
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      stub('gr-avatar', {
+        _getConfig: () => {
+          return Promise.resolve({});
+        },
       });
 
-      // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
-
-      return Promise.all([
-        element.$.restAPI.getConfig(),
-        Gerrit.awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-      });
+      element = fixture('basic');
     });
 
-    test('avatar config not set and account set', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
+    teardown(() => {
+      sandbox.restore();
+    });
 
-      sandbox.stub(element, '_getConfig', () => {
-        return Promise.resolve({});
-      });
+    test('avatar hidden when account set', () => {
+      flush(() => {
+        assert.isFalse(element.hasAttribute('hidden'));
 
-      element.imageSize = 64;
-      element.account = {
-        _account_id: 123,
-      };
+        element.imageSize = 64;
+        element.account = {
+          _account_id: 123,
+        };
+        // Emulate plugins loaded.
+        Gerrit._loadPlugins([]);
 
-      // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
-
-      return Promise.all([
-        element.$.restAPI.getConfig(),
-        Gerrit.awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
+        return Promise.all([
+          element.$.restAPI.getConfig(),
+          Gerrit.awaitPluginsLoaded(),
+        ]).then(() => {
+          assert.isTrue(element.hasAttribute('hidden'));
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index cdf617f..87caf64 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/paper-button/paper-button.html">
+<link rel="import" href="/bower_components/paper-button/paper-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-button">
@@ -39,6 +39,37 @@
         text-transform: none;
       }
       paper-button {
+        /* The next lines contains a copy of paper-button style.
+          Without a copy, the @apply works incorrectly with Polymer 2.
+          @apply is deprecated and is not recommended to use. It is expected
+          that @apply will be replaced with the ::part CSS pseudo-element.
+          After replacecment copied lines can be removed.
+        */
+        @apply --layout-inline;
+        @apply --layout-center-center;
+        position: relative;
+        box-sizing: border-box;
+        min-width: 5.14em;
+        margin: 0 0.29em;
+        background: transparent;
+        -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+        -webkit-tap-highlight-color: transparent;
+        font: inherit;
+        text-transform: uppercase;
+        outline-width: 0;
+        border-radius: var(--border-radius);
+        -moz-user-select: none;
+        -ms-user-select: none;
+        -webkit-user-select: none;
+        user-select: none;
+        cursor: pointer;
+        z-index: 0;
+        padding: var(--spacing-m);
+
+        @apply --paper-font-common-base;
+        @apply --paper-button;
+        /* End of copy*/
+
         /* paper-button sets this to anti-aliased, which appears different than
           bold font elsewhere on macOS. */
         -webkit-font-smoothing: initial;
@@ -53,6 +84,24 @@
         padding: var(--padding, 4px 8px);
         @apply --gr-button;
       }
+      /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
+      /* BEGIN: Copy from paper-button */
+      paper-button[elevation="1"] {
+        @apply --paper-material-elevation-1;
+      }
+      paper-button[elevation="2"] {
+        @apply --paper-material-elevation-2;
+      }
+      paper-button[elevation="3"] {
+        @apply --paper-material-elevation-3;
+      }
+      paper-button[elevation="4"] {
+        @apply --paper-material-elevation-4;
+      }
+      paper-button[elevation="5"] {
+        @apply --paper-material-elevation-5;
+      }
+      /* END: Copy from paper-button */
       paper-button:hover {
         background: linear-gradient(
           rgba(0, 0, 0, .12),
@@ -91,6 +140,8 @@
       }
       :host([disabled][link]) {
         --background-color: transparent;
+        --text-color: var(--deemphasized-text-color);
+        cursor: default;
       }
 
       /* Styles for the optional down arrow */
@@ -101,8 +152,8 @@
         border-top: .36em solid #ccc;
         border-left: .36em solid transparent;
         border-right: .36em solid transparent;
-        margin-bottom: .05em;
-        margin-left: .5em;
+        margin-bottom: var(--spacing-xxs);
+        margin-left: var(--spacing-m);
         transition: border-top-color 200ms;
       }
       :host([down-arrow]) paper-button:hover .downArrow {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 5988cde..afc6ba8 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-button',
-    _legacyUndefinedCheck: true,
 
     properties: {
       tooltip: String,
@@ -53,7 +52,6 @@
     },
 
     listeners: {
-      tap: '_handleAction',
       click: '_handleAction',
       keydown: '_handleKeydown',
     },
@@ -81,7 +79,7 @@
 
     _disabledChanged(disabled) {
       if (disabled) {
-        this._enabledTabindex = this.getAttribute('tabindex');
+        this._enabledTabindex = this.getAttribute('tabindex') || '0';
       }
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
       this.updateStyles();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index ed0da2e..ef593c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-button</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-button.html">
 
@@ -39,7 +41,11 @@
 
     const addSpyOn = function(eventName) {
       const spy = sandbox.spy();
-      element.addEventListener(eventName, spy);
+      if (eventName == 'tap') {
+        Polymer.Gestures.addListener(element, eventName, spy);
+      } else {
+        element.addEventListener(eventName, spy);
+      }
       return spy;
     };
 
@@ -62,6 +68,23 @@
       assert.isTrue(element.$$('paper-button').disabled);
     });
 
+    test('tabindex should be -1 if disabled', () => {
+      element.disabled = true;
+      assert.isTrue(element.getAttribute('tabindex') === '-1');
+    });
+
+    // Regression tests for Issue: 11969
+    test('tabindex should be reset to 0 if enabled', () => {
+      element.disabled = false;
+      assert.isTrue(element.getAttribute('tabindex') === '0');
+      element.disabled = true;
+      assert.isTrue(element.getAttribute('tabindex') === '-1');
+      element.disabled = false;
+      assert.isTrue(element.getAttribute('tabindex') === '0');
+    });
+
+    // 'tap' event is tested so we don't loose backward compatibility with older
+    // plugins who didn't move to on-click which is faster and well supported.
     for (const eventName of ['tap', 'click']) {
       test('dispatches ' + eventName + ' event', () => {
         const spy = addSpyOn(eventName);
@@ -72,16 +95,16 @@
 
     // Keycodes: 32 for Space, 13 for Enter.
     for (const key of [32, 13]) {
-      test('dispatches tap event on keycode ' + key, () => {
+      test('dispatches click event on keycode ' + key, () => {
         const tapSpy = sandbox.spy();
-        element.addEventListener('tap', tapSpy);
+        element.addEventListener('click', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
         assert.isTrue(tapSpy.calledOnce);
       });
 
-      test('dispatches no tap event with modifier on keycode ' + key, () => {
+      test('dispatches no click event with modifier on keycode ' + key, () => {
         const tapSpy = sandbox.spy();
-        element.addEventListener('tap', tapSpy);
+        element.addEventListener('click', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
         MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
         MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
@@ -105,9 +128,9 @@
 
       // Keycodes: 32 for Space, 13 for Enter.
       for (const key of [32, 13]) {
-        test('stops tap event on keycode ' + key, () => {
+        test('stops click event on keycode ' + key, () => {
           const tapSpy = sandbox.spy();
-          element.addEventListener('tap', tapSpy);
+          element.addEventListener('click', tapSpy);
           MockInteractions.pressAndReleaseKeyOn(element, key);
           assert.isFalse(tapSpy.called);
         });
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index a14c652..3774529 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -30,7 +30,7 @@
         fill: var(--link-color);
       }
     </style>
-    <button aria-label="Change star" on-tap="toggleStar">
+    <button aria-label="Change star" on-click="toggleStar">
       <iron-icon
           class$="[[_computeStarClass(change.starred)]]"
           icon$="[[_computeStarIcon(change.starred)]]"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 44f8c00..a83bc2b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-change-star',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when star state is toggled.
@@ -49,6 +48,7 @@
       this.set('change.starred', newVal);
       this.dispatchEvent(new CustomEvent('toggle-star', {
         bubbles: true,
+        composed: true,
         detail: {change: this.change, starred: newVal},
       }));
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index 0ca9368..7ee22a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-star</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-change-star.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
index 99ddff1..55623b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
@@ -25,11 +25,9 @@
   <template>
     <style include="shared-styles">
       .chip {
-        border-radius: 4px;
+        border-radius: var(--border-radius);
         background-color: var(--chip-background-color);
-        font-family: var(--font-family);
-        font-size: var(--font-size-normal);
-        padding: .1em .5em;
+        padding: var(--spacing-xxs) var(--spacing-m);
         white-space: nowrap;
       }
       :host(.merged) .chip {
@@ -66,7 +64,7 @@
       }
       :host([flat]) .chip {
         background-color: transparent;
-        padding: .1em;
+        padding: var(--spacing-xxs);
       }
       :host(:not([flat])) .chip {
         color: white;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index 70c5d72..e6f52c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -29,12 +29,15 @@
       'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
       'and email notifications will be silenced until the review is started.';
 
+  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+      'Download the patch and run "git rebase master". ' +
+      'Upload a new patchset after resolving all merge conflicts.';
+
   const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
       'current reviewers (or anyone with "View Private Changes" permission).';
 
   Polymer({
     is: 'gr-change-status',
-    _legacyUndefinedCheck: true,
 
     properties: {
       flat: {
@@ -76,6 +79,9 @@
         case ChangeStates.PRIVATE:
           this.tooltipText = PRIVATE_TOOLTIP;
           break;
+        case ChangeStates.MERGE_CONFLICT:
+          this.tooltipText = MERGE_CONFLICT_TOOLTIP;
+          break;
         default:
           this.tooltipText = '';
           break;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
index f73fc02..421c6ab5 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-status</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-change-status.html">
 
@@ -33,6 +35,17 @@
 </test-fixture>
 
 <script>
+  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+      'and email notifications will be silenced until the review is started.';
+
+  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+    'Download the patch and run "git rebase master". ' +
+    'Upload a new patchset after resolving all merge conflicts.';
+
+  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+      'current reviewers (or anyone with "View Private Changes" permission).';
+
   suite('gr-change-status tests', () => {
     let element;
     let sandbox;
@@ -49,7 +62,7 @@
     test('WIP', () => {
       element.status = 'WIP';
       assert.equal(element.$$('.chip').innerText, 'Work in Progress');
-      assert.isDefined(element.tooltipText);
+      assert.equal(element.tooltipText, WIP_TOOLTIP);
       assert.isTrue(element.classList.contains('wip'));
     });
 
@@ -79,14 +92,14 @@
     test('merge conflict', () => {
       element.status = 'Merge Conflict';
       assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
+      assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
       assert.isTrue(element.classList.contains('merge-conflict'));
     });
 
     test('private', () => {
       element.status = 'Private';
       assert.equal(element.$$('.chip').innerText, element.status);
-      assert.isDefined(element.tooltipText);
+      assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
       assert.isTrue(element.classList.contains('private'));
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
index 8c80b37..bbd7ddf 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -27,20 +28,32 @@
 <dom-module id="gr-comment-thread">
   <template>
     <style include="shared-styles">
+      :host {
+        font-family: var(--font-family);
+        font-size: var(--font-size-normal);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-normal);
+      }
       gr-button {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       #actions {
         margin-left: auto;
-        padding: .5em .7em;
+        padding: var(--spacing-m);
       }
       #container {
         background-color: var(--comment-background-color);
-        border: 1px solid var(--border-color);
         color: var(--comment-text-color);
         display: block;
-        margin-bottom: 1px;
+        margin: 0 4px 4px 4px;
         white-space: normal;
+        box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
+        border-radius: var(--border-radius);
+        /** This is required for firefox to continue the inheritance */
+        -webkit-user-select: inherit;
+        -moz-user-select: inherit;
+        -ms-user-select: inherit;
+        user-select: inherit;
       }
       #container.unresolved {
         background-color: var(--unresolved-comment-background-color);
@@ -52,14 +65,14 @@
       #unresolvedLabel {
         font-family: var(--font-family);
         margin: auto 0;
-        padding: .5em .7em;
+        padding: var(--spacing-m);
       }
       .pathInfo {
         display: flex;
         align-items: baseline;
       }
       .descriptionText {
-        margin-left: .5rem;
+        margin-left: var(--spacing-m);
         font-style: italic;
       }
     </style>
@@ -96,25 +109,25 @@
               link
               secondary
               class="action reply"
-              on-tap="_handleCommentReply">Reply</gr-button>
+              on-click="_handleCommentReply">Reply</gr-button>
           <gr-button
               id="quoteBtn"
               link
               secondary
               class="action quote"
-              on-tap="_handleCommentQuote">Quote</gr-button>
+              on-click="_handleCommentQuote">Quote</gr-button>
           <gr-button
               id="ackBtn"
               link
               secondary
               class="action ack"
-              on-tap="_handleCommentAck">Ack</gr-button>
+              on-click="_handleCommentAck">Ack</gr-button>
           <gr-button
               id="doneBtn"
               link
               secondary
               class="action done"
-              on-tap="_handleCommentDone">Done</gr-button>
+              on-click="_handleCommentDone">Done</gr-button>
         </div>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 0b4f806..c220ecf 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-comment-thread',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the thread should be discarded.
@@ -127,6 +126,10 @@
     },
 
     behaviors: [
+      /**
+       * Not used in this element rather other elements tests
+       */
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PathListBehavior,
     ],
@@ -209,7 +212,8 @@
     },
 
     _hideActions(_showActions, _lastComment) {
-      return !_showActions || !_lastComment || !!_lastComment.__draft;
+      return !_showActions || !_lastComment || !!_lastComment.__draft ||
+        !!_lastComment.robot_id;
     },
 
     _getLastComment() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
index 74c6f5f..d3946e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-thread</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -159,6 +161,9 @@
       showActions = true;
       lastComment.__draft = true;
       assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(element._hideActions(showActions, robotComment), true);
     });
 
     test('setting project name loads the project config', done => {
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 a470285..8ad261c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -15,9 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
@@ -41,7 +42,7 @@
       :host {
         display: block;
         font-family: var(--font-family);
-        padding: .7em .7em;
+        padding: var(--spacing-m);
         --iron-autogrow-textarea: {
           box-sizing: border-box;
           padding: 2px;
@@ -62,23 +63,17 @@
         align-items: baseline;
         cursor: pointer;
         display: flex;
-        font-family: 'Open Sans', sans-serif;
-        margin: -.7em -.7em 0 -.7em;
-        padding: .7em;
+        margin: calc(0px - var(--spacing-m)) calc(0px - var(--spacing-m)) 0 calc(0px - var(--spacing-m));
+        padding: var(--spacing-m);
       }
       .container.collapsed .header {
-        margin-bottom: -.7em;
+        margin-bottom: calc(0 - var(--spacing-m));
       }
       .headerMiddle {
         color: var(--deemphasized-text-color);
         flex: 1;
         overflow: hidden;
       }
-      .authorName,
-      .draftLabel,
-      .draftTooltip {
-        font-weight: var(--font-weight-bold);
-      }
       .draftLabel,
       .draftTooltip {
         color: var(--deemphasized-text-color);
@@ -103,25 +98,33 @@
         padding-top: 0;
       }
       .action {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       .robotActions {
         display: flex;
         justify-content: flex-start;
-        padding-top: 0;
+        padding-top: var(--spacing-m);
+        border-top: 1px solid var(--border-color);
       }
       .robotActions .action {
         /* Keep button text lined up with output text */
-        margin-left: -.3rem;
-        margin-right: 1em;
+        margin-left: -4px;
+        margin-right: var(--spacing-l);
       }
       .rightActions {
         display: flex;
         justify-content: flex-end;
       }
+      .rightActions gr-button {
+        --gr-button: {
+          height: 20px;
+          padding: 0 var(--spacing-s);
+          color: var(--default-button-text-color);
+        }
+      }
       .editMessage {
         display: none;
-        margin: .5em 0;
+        margin: var(--spacing-m) 0;
         width: 100%;
       }
       .container:not(.draft) .actions .hideOnPublished {
@@ -155,38 +158,37 @@
         display: block;
       }
       .show-hide {
-        margin-left: .4em;
+        margin-left: var(--spacing-s);
       }
       .robotId {
         color: var(--deemphasized-text-color);
-        margin-bottom: .8em;
+        margin-bottom: var(--spacing-m);
         margin-top: -.4em;
       }
       .robotIcon {
-        margin-right: .2em;
+        margin-right: var(--spacing-xs);
         /* because of the antenna of the robot, it looks off center even when it
          is centered. artificially adjust margin to account for this. */
-        margin-top: -.3em;
+        margin-top: -4px;
       }
       .runIdInformation {
-        margin: .7em 0;
+        margin: var(--spacing-m) 0;
       }
       .robotRun {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       .robotRunLink {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       input.show-hide {
         display: none;
       }
       label.show-hide {
-        color: var(--comment-text-color);
         cursor: pointer;
         display: block;
-        font-size: .8rem;
-        height: 1.1em;
-        margin-top: .1em;
+      }
+      label.show-hide iron-icon {
+        vertical-align: top;
       }
       #container .collapsedContent {
         display: none;
@@ -231,9 +233,19 @@
       #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">
+      <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
           <span class="draftLabel">DRAFT</span>
@@ -251,10 +263,10 @@
             link
             secondary
             class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-            on-tap="_handleCommentDelete">
+            on-click="_handleCommentDelete">
           (Delete)
         </gr-button>
-        <span class="date" on-tap="_handleAnchorTap">
+        <span class="date" on-click="_handleAnchorClick">
           <gr-date-formatter
               has-tooltip
               date-str="[[comment.updated]]"></gr-date-formatter>
@@ -264,7 +276,10 @@
             <input type="checkbox" class="show-hide"
                checked$="[[collapsed]]"
                on-change="_handleToggleCollapsed">
-            [[_computeShowHideText(collapsed)]]
+            <iron-icon
+                id="icon"
+                icon="[[_computeShowHideIcon(collapsed)]]">
+            </iron-icon>
           </label>
         </div>
       </div>
@@ -280,7 +295,7 @@
               id="editTextarea"
               class="editMessage"
               autocomplete="on"
-              monospace
+              code
               disabled="{{disabled}}"
               rows="4"
               text="{{_messageText}}"></gr-textarea>
@@ -320,23 +335,23 @@
                 link
                 secondary
                 class="action cancel hideOnPublished"
-                on-tap="_handleCancel">Cancel</gr-button>
+                on-click="_handleCancel">Cancel</gr-button>
             <gr-button
                 link
                 secondary
                 class="action discard hideOnPublished"
-                on-tap="_handleDiscard">Discard</gr-button>
+                on-click="_handleDiscard">Discard</gr-button>
             <gr-button
                 link
                 secondary
                 class="action edit hideOnPublished"
-                on-tap="_handleEdit">Edit</gr-button>
+                on-click="_handleEdit">Edit</gr-button>
             <gr-button
                 link
                 secondary
                 disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
                 class="action save hideOnPublished"
-                on-tap="_handleSave">Save</gr-button>
+                on-click="_handleSave">Save</gr-button>
           </div>
         </div>
         <div class="robotActions" hidden$="[[!_showRobotActions]]">
@@ -345,7 +360,7 @@
                 link
                 secondary
                 class="action fix"
-                on-tap="_handleFix"
+                on-click="_handleFix"
                 disabled="[[robotButtonDisabled]]">
               Please Fix
             </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index b699b8b..6881929 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -33,7 +33,6 @@
 
   Polymer({
     is: 'gr-comment',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the create fix comment action is triggered.
@@ -128,7 +127,8 @@
 
       _numPendingDraftRequests: {
         type: Object,
-        value: {number: 0}, // Intentional to share the object across instances.
+        value:
+            {number: 0}, // Intentional to share the object across instances.
       },
 
       _enableOverlay: {
@@ -156,6 +156,7 @@
     ],
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -204,11 +205,16 @@
       return this._overlays.confirmDiscard;
     },
 
-    _computeShowHideText(collapsed) {
-      return collapsed ? '◀' : '▼';
+    _computeShowHideIcon(collapsed) {
+      return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
     },
 
     _calculateActionstoShow(showActions, isRobotComment) {
+      // Polymer 2: check for undefined
+      if ([showActions, isRobotComment].some(arg => arg === undefined)) {
+        return;
+      }
+
       this._showHumanActions = showActions && !isRobotComment;
       this._showRobotActions = showActions && isRobotComment;
     },
@@ -230,7 +236,9 @@
      */
     save(opt_comment) {
       let comment = opt_comment;
-      if (!comment) { comment = this.comment; }
+      if (!comment) {
+        comment = this.comment;
+      }
 
       this.set('comment.message', this._messageText);
       this.editing = false;
@@ -315,6 +323,11 @@
     },
 
     _editingChanged(editing, previousValue) {
+      // Polymer 2: observer fires when at least one property is defined.
+      // Do nothing to prevent comment.__editing being overwritten
+      // if previousValue is undefined
+      if (previousValue === undefined) return;
+
       this.$.container.classList.toggle('editing', editing);
       if (this.comment && this.comment.id) {
         this.$$('.cancel').hidden = !editing;
@@ -340,7 +353,9 @@
 
     _computeSaveDisabled(draft, comment, resolved) {
       // If resolved state has changed and a msg exists, save should be enabled.
-      if (comment.unresolved === resolved && draft) { return false; }
+      if (!comment || comment.unresolved === resolved && draft) {
+        return false;
+      }
       return !draft || draft.trim() === '';
     },
 
@@ -376,7 +391,9 @@
     },
 
     _messageTextChanged(newValue, oldValue) {
-      if (!this.comment || (this.comment && this.comment.id)) { return; }
+      if (!this.comment || (this.comment && this.comment.id)) {
+        return;
+      }
 
       this.debounce('store', () => {
         const message = this._messageText;
@@ -398,11 +415,14 @@
       }, STORAGE_DEBOUNCE_INTERVAL);
     },
 
-    _handleAnchorTap(e) {
+    _handleAnchorClick(e) {
       e.preventDefault();
-      if (!this.comment.line) { return; }
+      if (!this.comment.line) {
+        return;
+      }
       this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
         bubbles: true,
+        composed: true,
         detail: {
           number: this.comment.line || FILE,
           side: this.side,
@@ -421,7 +441,9 @@
       e.preventDefault();
 
       // Ignore saves started while already saving.
-      if (this.disabled) { return; }
+      if (this.disabled) {
+        return;
+      }
       const timingLabel = this.comment.id ?
         REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
       const timer = this.$.reporting.getTimer(timingLabel);
@@ -450,6 +472,7 @@
     _handleFix() {
       this.dispatchEvent(new CustomEvent('create-fix-comment', {
         bubbles: true,
+        composed: true,
         detail: this._getEventPayload(),
       }));
     },
@@ -512,7 +535,9 @@
     },
 
     _getSavingMessage(numPending) {
-      if (numPending === 0) { return SAVED_MESSAGE; }
+      if (numPending === 0) {
+        return SAVED_MESSAGE;
+      }
       return [
         SAVING_MESSAGE,
         numPending,
@@ -544,8 +569,8 @@
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
         // case the event would not bubble.
-        document.body.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true}));
+        document.body.dispatchEvent(new CustomEvent(
+            'show-alert', {detail: {message}, bubbles: true, composed: true}));
       }, TOAST_DEBOUNCE_INTERVAL);
     },
 
@@ -580,6 +605,11 @@
     },
 
     _loadLocalDraft(changeNum, patchNum, comment) {
+      // Polymer 2: check for undefined
+      if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
+        return;
+      }
+
       // Only apply local drafts to comments that haven't been saved
       // remotely, and haven't been given a default message already.
       //
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
index 7ca5242..c829343 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-comment.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
index 9decfa9..62ab307 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<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-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -42,6 +43,8 @@
       }
       iron-autogrow-textarea {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         padding: 0;
         width: 73ch; /* Add a char to account for the border. */
 
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
index 4ac059d..c2075f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-confirm-delete-comment-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -37,17 +36,23 @@
       message: String,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     resetFocus() {
       this.$.messageInput.textarea.focus();
     },
 
     _handleConfirmTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', {reason: this.message}, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
index 32ca557..f58db39 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
@@ -31,36 +31,53 @@
       }
       .copyText {
         flex-grow: 1;
-        margin-right: .3em;
+        margin-right: var(--spacing-s);
       }
       .hideInput {
         display: none;
       }
-      input {
+      input#input {
         font-family: var(--monospace-font-family);
-        font-size: inherit;
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
         @apply --text-container-style;
+        width: 100%;
       }
       #icon {
         height: 1.2em;
         width: 1.2em;
       }
+      gr-button {
+        --gr-button: {
+          padding: 1px 4px;
+        }
+      }
+
     </style>
     <div class="text">
-        <input id="input" is="iron-input"
-            class$="copyText [[_computeInputClass(hideInput)]]"
+      <iron-input
+          class="copyText"
+          type="text"
+          bind-value="[[text]]"
+          on-tap="_handleInputClick"
+          readonly>
+        <input
+            id="input"
+            is="iron-input"
+            class$="[[_computeInputClass(hideInput)]]"
             type="text"
             bind-value="[[text]]"
-            on-tap="_handleInputTap"
+            on-click="_handleInputClick"
             readonly>
-        <gr-button id="button"
-            link
-            has-tooltip="[[hasTooltip]]"
-            class="copyToClipboard"
-            title="[[buttonTitle]]"
-            on-tap="_copyToClipboard">
-          <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-        </gr-button>
+      </iron-input>
+      <gr-button id="button"
+          link
+          has-tooltip="[[hasTooltip]]"
+          class="copyToClipboard"
+          title="[[buttonTitle]]"
+          on-click="_copyToClipboard">
+        <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+      </gr-button>
     </div>
   </template>
   <script src="gr-copy-clipboard.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index 550f1df..3e87202 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-copy-clipboard',
-    _legacyUndefinedCheck: true,
 
     properties: {
       text: String,
@@ -44,7 +43,7 @@
       return hideInput ? 'hideInput' : '';
     },
 
-    _handleInputTap(e) {
+    _handleInputClick(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index d6e9dca..9cec20e 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-copy-clipboard</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-copy-clipboard.html">
 
@@ -37,12 +39,13 @@
     let element;
     let sandbox;
 
-    setup(() => {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
       element.text = `git fetch http://gerrit@localhost:8080/a/test-project
           refs/changes/05/5/1 && git checkout FETCH_HEAD`;
       flushAsynchronousOperations();
+      flush(done);
     });
 
     teardown(() => {
@@ -62,7 +65,7 @@
           element.$$('.copyToClipboard'));
     });
 
-    test('_handleInputTap', () => {
+    test('_handleInputClick', () => {
       const inputElement = element.$$('input');
       MockInteractions.tap(inputElement);
       assert.equal(inputElement.selectionStart, 0);
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
index e4d896b..d061ac2 100644
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-count-string-formatter</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-count-string-formatter.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
index d619b18..94d7aaa 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-cursor-manager">
   <template></template>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 766ac7f..b97726e 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -24,7 +24,6 @@
 
   Polymer({
     is: 'gr-cursor-manager',
-    _legacyUndefinedCheck: true,
 
     properties: {
       stops: {
@@ -92,8 +91,22 @@
       this.unsetCursor();
     },
 
-    next(opt_condition, opt_getTargetHeight) {
-      this._moveCursor(1, opt_condition, opt_getTargetHeight);
+    /**
+     * Move the cursor forward. Clipped to the ends of the stop list.
+     *
+     * @param {!Function=} opt_condition Optional stop condition. If a condition
+     *    is passed the cursor will continue to move in the specified direction
+     *    until the condition is met.
+     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+     *    height of the target's 'section'. The height of the target itself is
+     *    sometimes different, used by the diff cursor.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
+     * @private
+     */
+
+    next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
+      this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
     },
 
     previous(opt_condition) {
@@ -148,8 +161,8 @@
     },
 
     /**
-     * Move the cursor forward or backward by delta. Noop if moving past either
-     * end of the stop list.
+     * Move the cursor forward or backward by delta. Clipped to the beginning or
+     * end of stop list.
      *
      * @param {number} delta either -1 or 1.
      * @param {!Function=} opt_condition Optional stop condition. If a condition
@@ -158,9 +171,11 @@
      * @param {!Function=} opt_getTargetHeight Optional function to calculate the
      *    height of the target's 'section'. The height of the target itself is
      *    sometimes different, used by the diff cursor.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
      * @private
      */
-    _moveCursor(delta, opt_condition, opt_getTargetHeight) {
+    _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
       if (!this.stops.length) {
         this.unsetCursor();
         return;
@@ -168,7 +183,7 @@
 
       this._unDecorateTarget();
 
-      const newIndex = this._getNextindex(delta, opt_condition);
+      const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
 
       let newTarget = null;
       if (newIndex !== -1) {
@@ -208,10 +223,12 @@
      *
      * @param {number} delta either -1 or 1.
      * @param {!Function=} opt_condition Optional stop condition.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
      * @return {number} the new index.
      * @private
      */
-    _getNextindex(delta, opt_condition) {
+    _getNextindex(delta, opt_condition, opt_clipToTop) {
       if (!this.stops.length || this.index === -1) {
         return -1;
       }
@@ -227,10 +244,10 @@
 
       // If we failed to satisfy the condition:
       if (opt_condition && !opt_condition(this.stops[newIndex])) {
-        if (delta > 0) {
-          return this.stops.length - 1;
-        } else if (delta < 0) {
+        if (delta < 0 || opt_clipToTop) {
           return 0;
+        } else if (delta > 0) {
+          return this.stops.length - 1;
         }
         return this.index;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index adbe618..0793ccd 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cursor-manager</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-cursor-manager.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 481dd2f..ae5a945 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -31,7 +31,7 @@
       }
     </style>
     <span>
-      [[_computeDateStr(dateStr, _timeFormat, _relative, showDateAndTime)]]
+      [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative, showDateAndTime)]]
     </span>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index a9ce4c9..545a7c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -27,13 +27,33 @@
     TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
     TIME_24: 'HH:mm', // 14:14
     TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
-    MONTH_DAY: 'MMM DD', // Aug 29
-    MONTH_DAY_YEAR: 'MMM DD, YYYY', // Aug 29, 1997
+  };
+
+  const DateFormats = {
+    STD: {
+      short: 'MMM DD', // Aug 29
+      full: 'MMM DD, YYYY', // Aug 29, 1997
+    },
+    US: {
+      short: 'MM/DD', // 08/29
+      full: 'MM/DD/YY', // 08/29/97
+    },
+    ISO: {
+      short: 'MM-DD', // 08-29
+      full: 'YYYY-MM-DD', // 1997-08-29
+    },
+    EURO: {
+      short: 'DD. MMM', // 29. Aug
+      full: 'DD.MM.YYYY', // 29.08.1997
+    },
+    UK: {
+      short: 'DD/MM', // 29/08
+      full: 'DD/MM/YYYY', // 29/08/1997
+    },
   };
 
   Polymer({
     is: 'gr-date-formatter',
-    _legacyUndefinedCheck: true,
 
     properties: {
       dateStr: {
@@ -58,9 +78,11 @@
       title: {
         type: String,
         reflectToAttribute: true,
-        computed: '_computeFullDateStr(dateStr, _timeFormat)',
+        computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
       },
 
+      /** @type {?{short: string, full: string}} */
+      _dateFormat: Object,
       _timeFormat: String, // No default value to prevent flickering.
       _relative: Boolean, // No default value to prevent flickering.
     },
@@ -81,6 +103,7 @@
       return this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) {
           this._timeFormat = TimeFormats.TIME_24;
+          this._dateFormat = DateFormats.STD;
           this._relative = false;
           return;
         }
@@ -94,19 +117,47 @@
     _loadTimeFormat() {
       return this._getPreferences().then(preferences => {
         const timeFormat = preferences && preferences.time_format;
-        switch (timeFormat) {
-          case 'HHMM_12':
-            this._timeFormat = TimeFormats.TIME_12;
-            break;
-          case 'HHMM_24':
-            this._timeFormat = TimeFormats.TIME_24;
-            break;
-          default:
-            throw Error('Invalid time format: ' + timeFormat);
-        }
+        const dateFormat = preferences && preferences.date_format;
+        this._decideTimeFormat(timeFormat);
+        this._decideDateFormat(dateFormat);
       });
     },
 
+    _decideTimeFormat(timeFormat) {
+      switch (timeFormat) {
+        case 'HHMM_12':
+          this._timeFormat = TimeFormats.TIME_12;
+          break;
+        case 'HHMM_24':
+          this._timeFormat = TimeFormats.TIME_24;
+          break;
+        default:
+          throw Error('Invalid time format: ' + timeFormat);
+      }
+    },
+
+    _decideDateFormat(dateFormat) {
+      switch (dateFormat) {
+        case 'STD':
+          this._dateFormat = DateFormats.STD;
+          break;
+        case 'US':
+          this._dateFormat = DateFormats.US;
+          break;
+        case 'ISO':
+          this._dateFormat = DateFormats.ISO;
+          break;
+        case 'EURO':
+          this._dateFormat = DateFormats.EURO;
+          break;
+        case 'UK':
+          this._dateFormat = DateFormats.UK;
+          break;
+        default:
+          throw Error('Invalid date format: ' + dateFormat);
+      }
+    },
+
     _loadRelative() {
       return this._getPreferences().then(prefs => {
         // prefs.relative_date_in_change_table is not set when false.
@@ -139,8 +190,10 @@
           diff < 180 * Duration.DAY;
     },
 
-    _computeDateStr(dateStr, timeFormat, relative, showDateAndTime) {
-      if (!dateStr) { return ''; }
+    _computeDateStr(
+        dateStr, timeFormat, dateFormat, relative, showDateAndTime
+    ) {
+      if (!dateStr || !timeFormat || !dateFormat) { return ''; }
       const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
       if (relative) {
@@ -152,12 +205,12 @@
         }
       }
       const now = new Date();
-      let format = TimeFormats.MONTH_DAY_YEAR;
+      let format = dateFormat.full;
       if (this._isWithinDay(now, date)) {
         format = timeFormat;
       } else {
         if (this._isWithinHalfYear(now, date)) {
-          format = TimeFormats.MONTH_DAY;
+          format = dateFormat.short;
         }
         if (this.showDateAndTime) {
           format = `${format} ${timeFormat}`;
@@ -172,11 +225,20 @@
         TimeFormats.TIME_24_WITH_SEC;
     },
 
-    _computeFullDateStr(dateStr, timeFormat) {
+    _computeFullDateStr(dateStr, timeFormat, dateFormat) {
+      // Polymer 2: check for undefined
+      if ([
+        dateStr,
+        timeFormat,
+        dateFormat,
+      ].some(arg => arg === undefined)) {
+        return undefined;
+      }
+
       if (!dateStr) { return ''; }
       const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
-      let format = TimeFormats.MONTH_DAY_YEAR + ', ';
+      let format = dateFormat.full + ', ';
       format += this._timeToSecondsFormat(timeFormat);
       return date.format(format) + this._getUtcOffsetString();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 798aa68b1..d51b5d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-date-formatter</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -84,16 +86,16 @@
       return Promise.all([loggedInPromise, preferencesPromise]);
     }
 
-    suite('24 hours time format preference', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_24', relative_date_in_change_table: false}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        });
-      });
+    suite('STD + 24 hours time format preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'STD',
+        relative_date_in_change_table: false,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
 
       test('invalid dates are quietly rejected', () => {
         assert.notOk((new Date('foo')).valueOf());
@@ -133,17 +135,161 @@
       });
     });
 
-    suite('12 hours time format preference', () => {
-      setup(() => {
+    suite('US + 24 hours time format preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'US',
+        relative_date_in_change_table: false,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '15:34',
+            '15:34',
+            '07/29/15, 15:34:14', done);
+      });
+
+      test('Within 24 hours on different days', done => {
+        testDates('2015-07-29 03:34:14.985000000',
+            '2015-07-28 20:25:14.985000000',
+            '07/28',
+            '07/28 20:25',
+            '07/28/15, 20:25:14', done);
+      });
+
+      test('More than 24 hours but less than six months', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-06-15 03:25:14.985000000',
+            '06/15',
+            '06/15 03:25',
+            '06/15/15, 03:25:14', done);
+      });
+    });
+
+    suite('ISO + 24 hours time format preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'ISO',
+        relative_date_in_change_table: false,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '15:34',
+            '15:34',
+            '2015-07-29, 15:34:14', done);
+      });
+
+      test('Within 24 hours on different days', done => {
+        testDates('2015-07-29 03:34:14.985000000',
+            '2015-07-28 20:25:14.985000000',
+            '07-28',
+            '07-28 20:25',
+            '2015-07-28, 20:25:14', done);
+      });
+
+      test('More than 24 hours but less than six months', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-06-15 03:25:14.985000000',
+            '06-15',
+            '06-15 03:25',
+            '2015-06-15, 03:25:14', done);
+      });
+    });
+
+    suite('EURO + 24 hours time format preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'EURO',
+        relative_date_in_change_table: false,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '15:34',
+            '15:34',
+            '29.07.2015, 15:34:14', done);
+      });
+
+      test('Within 24 hours on different days', done => {
+        testDates('2015-07-29 03:34:14.985000000',
+            '2015-07-28 20:25:14.985000000',
+            '28. Jul',
+            '28. Jul 20:25',
+            '28.07.2015, 20:25:14', done);
+      });
+
+      test('More than 24 hours but less than six months', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-06-15 03:25:14.985000000',
+            '15. Jun',
+            '15. Jun 03:25',
+            '15.06.2015, 03:25:14', done);
+      });
+    });
+
+    suite('UK + 24 hours time format preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'UK',
+        relative_date_in_change_table: false,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '15:34',
+            '15:34',
+            '29/07/2015, 15:34:14', done);
+      });
+
+      test('Within 24 hours on different days', done => {
+        testDates('2015-07-29 03:34:14.985000000',
+            '2015-07-28 20:25:14.985000000',
+            '28/07',
+            '28/07 20:25',
+            '28/07/2015, 20:25:14', done);
+      });
+
+      test('More than 24 hours but less than six months', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-06-15 03:25:14.985000000',
+            '15/06',
+            '15/06 03:25',
+            '15/06/2015, 03:25:14', done);
+      });
+    });
+
+    suite('STD + 12 hours time format preference', () => {
+      setup(() =>
         // relative_date_in_change_table is not set when false.
-        return stubRestAPI(
-            {time_format: 'HHMM_12'}
+        stubRestAPI(
+            {time_format: 'HHMM_12', date_format: 'STD'}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
-        });
-      });
+        })
+      );
 
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
@@ -154,16 +300,100 @@
       });
     });
 
-    suite('relative date preference', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_12', relative_date_in_change_table: true}
+    suite('US + 12 hours time format preference', () => {
+      setup(() =>
+        // relative_date_in_change_table is not set when false.
+        stubRestAPI(
+            {time_format: 'HHMM_12', date_format: 'US'}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
-        });
+        })
+      );
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '3:34 PM',
+            '3:34 PM',
+            '07/29/15, 3:34:14 PM', done);
       });
+    });
+
+    suite('ISO + 12 hours time format preference', () => {
+      setup(() =>
+        // relative_date_in_change_table is not set when false.
+        stubRestAPI(
+            {time_format: 'HHMM_12', date_format: 'ISO'}
+        ).then(() => {
+          element = fixture('basic');
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
+        })
+      );
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '3:34 PM',
+            '3:34 PM',
+            '2015-07-29, 3:34:14 PM', done);
+      });
+    });
+
+    suite('EURO + 12 hours time format preference', () => {
+      setup(() =>
+        // relative_date_in_change_table is not set when false.
+        stubRestAPI(
+            {time_format: 'HHMM_12', date_format: 'EURO'}
+        ).then(() => {
+          element = fixture('basic');
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
+        })
+      );
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '3:34 PM',
+            '3:34 PM',
+            '29.07.2015, 3:34:14 PM', done);
+      });
+    });
+
+    suite('UK + 12 hours time format preference', () => {
+      setup(() =>
+        // relative_date_in_change_table is not set when false.
+        stubRestAPI(
+            {time_format: 'HHMM_12', date_format: 'UK'}
+        ).then(() => {
+          element = fixture('basic');
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
+        })
+      );
+
+      test('Within 24 hours on same day', done => {
+        testDates('2015-07-29 20:34:14.985000000',
+            '2015-07-29 15:34:14.985000000',
+            '3:34 PM',
+            '3:34 PM',
+            '29/07/2015, 3:34:14 PM', done);
+      });
+    });
+
+    suite('relative date preference', () => {
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_12',
+        date_format: 'STD',
+        relative_date_in_change_table: true,
+      }).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
 
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
@@ -183,31 +413,33 @@
     });
 
     suite('logged in', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(() => {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI({
+        time_format: 'HHMM_12',
+        date_format: 'US',
+        relative_date_in_change_table: true,
+      }).then(() => {
+        element = fixture('basic');
+        return element._loadPreferences();
+      }));
 
       test('Preferences are respected', () => {
         assert.equal(element._timeFormat, 'h:mm A');
+        assert.equal(element._dateFormat.short, 'MM/DD');
+        assert.equal(element._dateFormat.full, 'MM/DD/YY');
         assert.isTrue(element._relative);
       });
     });
 
     suite('logged out', () => {
-      setup(() => {
-        return stubRestAPI(null).then(() => {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI(null).then(() => {
+        element = fixture('basic');
+        return element._loadPreferences();
+      }));
 
       test('Default preferences are respected', () => {
         assert.equal(element._timeFormat, 'HH:mm');
+        assert.equal(element._dateFormat.short, 'MMM DD');
+        assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
         assert.isFalse(element._relative);
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
index 797c8ea..2ef5539 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -45,10 +46,10 @@
       header,
       main,
       footer {
-        padding: .5em 1.5em;
+        padding: var(--spacing-m) var(--spacing-xl);
       }
       gr-button {
-        margin-left: 1em;
+        margin-left: var(--spacing-l);
       }
       footer {
         display: flex;
@@ -63,10 +64,10 @@
       <header><slot name="header"></slot></header>
       <main><slot name="main"></slot></main>
       <footer>
-        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-tap="_handleCancelTap">
+        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-click="_handleCancelTap">
           [[cancelLabel]]
         </gr-button>
-        <gr-button id="confirm" link primary on-tap="_handleConfirm" disabled="[[disabled]]">
+        <gr-button id="confirm" link primary on-click="_handleConfirm" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
       </footer>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
index b8b2af4..68dc537 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-dialog',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -53,6 +52,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     hostAttributes: {
       role: 'dialog',
     },
@@ -61,11 +64,13 @@
       if (this.disabled) { return; }
 
       e.preventDefault();
+      e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
     },
 
     _handleCancelTap(e) {
       e.preventDefault();
+      e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
index 4a5a181..1456e77 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dialog</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
index 1c9f469..9d85d44 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
@@ -60,43 +60,67 @@
       <section>
         <span class="title">Diff width</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
-              id="columnsInput"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{diffPrefs.line_length}}"
               on-keypress="_handleDiffPrefsChanged"
               on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="columnsInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.line_length}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Tab width</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
-              id="tabSizeInput"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{diffPrefs.tab_size}}"
               on-keypress="_handleDiffPrefsChanged"
               on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="tabSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.tab_size}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section hidden$="[[!diffPrefs.font_size]]">
         <span class="title">Font size</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
-              id="fontSizeInput"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{diffPrefs.font_size}}"
               on-keypress="_handleDiffPrefsChanged"
               on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="fontSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.font_size}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
index 89c3d74..36fdf5b 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-diff-preferences',
-    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
index 055d7aa..4d2a1a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-preferences</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-diff-preferences.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 6aec5a6..14a65b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
+<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
 <link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -29,7 +29,7 @@
     <style include="shared-styles">
       paper-tabs {
         height: 3rem;
-        margin-bottom: .5em;
+        margin-bottom: var(--spacing-m);
         --paper-tabs-selection-bar-color: var(--link-color);
       }
       paper-tab {
@@ -54,7 +54,7 @@
       }
       gr-shell-command {
         width: 60em;
-        margin-bottom: .5em;
+        margin-bottom: var(--spacing-m);
       }
       .hidden {
         display: none;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index ed7c2cc..121aa35 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-download-commands',
-    _legacyUndefinedCheck: true,
+
     properties: {
       commands: Array,
       _loggedIn: {
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index c59e56a..85a5b1f 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-commands</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-download-commands.html">
 
@@ -65,12 +67,13 @@
     });
 
     suite('unauthenticated', () => {
-      setup(() => {
+      setup(done => {
         element = fixture('basic');
         element.schemes = SCHEMES;
         element.commands = COMMANDS;
         element.selectedScheme = SELECTED_SCHEME;
         flushAsynchronousOperations();
+        flush(done);
       });
 
       test('focusOnCopy', () => {
@@ -89,13 +92,13 @@
         assert.isTrue(isHidden(element.$$('.commands')));
       });
 
-      test('tab selection', () => {
+      test('tab selection', done => {
         assert.equal(element.$.downloadTabs.selected, '0');
         MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
         flushAsynchronousOperations();
-
         assert.equal(element.selectedScheme, 'ssh');
         assert.equal(element.$.downloadTabs.selected, '2');
+        done();
       });
 
       test('loads scheme from preferences', done => {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 42b6f94..98d7bf6 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -14,11 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../bower_components/paper-item/paper-item.html">
-<link rel="import" href="../../../bower_components/paper-listbox/paper-listbox.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/paper-item/paper-item.html">
+<link rel="import" href="/bower_components/paper-listbox/paper-listbox.html">
 
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -46,9 +46,9 @@
         background-color: var(--dropdown-background-color);
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
         max-height: 70vh;
-        margin-top: 2em;
+        margin-top: var(--spacing-xxl);
         min-width: 266px;
-        @apply --dropdown-content-style
+        @apply --dropdown-content-style;
       }
       paper-listbox {
         --paper-listbox: {
@@ -58,7 +58,7 @@
       paper-item {
         cursor: pointer;
         flex-direction: column;
-        font-size: var(--font-size-normal);
+        font-size: inherit;
         --paper-item: {
           min-height: 0;
           padding: 10px 16px;
@@ -78,20 +78,10 @@
       }
       .bottomContent {
         color: var(--deemphasized-text-color);
-        /*
-         * Should be 16px when the base font size is 13px (browser default of
-         * 16px.
-         */
-        line-height: 1.37rem;
       }
       .bottomContent,
       .topContent {
         display: flex;
-        /*
-         * Should be 16px when the base font size is 13px (browser default of
-         * 16px.
-         */
-        line-height: 1.37rem;
         justify-content: space-between;
         flex-direction: row;
         width: 100%;
@@ -103,7 +93,7 @@
       }
       gr-date-formatter {
         color: var(--deemphasized-text-color);
-        margin-left: 2em;
+        margin-left: var(--spacing-xxl);
         white-space: nowrap;
       }
       gr-select {
@@ -135,11 +125,12 @@
       }
     </style>
     <gr-button
+        disabled="[[disabled]]"
         down-arrow
         link
         id="trigger"
         class="dropdown-trigger"
-        on-tap="_showDropdownTapHandler"
+        on-click="_showDropdownTapHandler"
         slot="dropdown-trigger">
       <span id="triggerText">[[text]]</span>
     </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index 6b3905f..efd1d0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -47,7 +47,6 @@
 
   Polymer({
     is: 'gr-dropdown-list',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the selected value changes
@@ -62,6 +61,10 @@
       /** @type {!Array<!Defs.item>} */
       items: Object,
       text: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+      },
       value: {
         type: String,
         notify: true,
@@ -106,6 +109,11 @@
     },
 
     _handleValueChange(value, items) {
+      // Polymer 2: check for undefined
+      if ([value, items].some(arg => arg === undefined)) {
+        return;
+      }
+
       if (!value) { return; }
       const selectedObj = items.find(item => {
         return item.value + '' === value + '';
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
index 87fd8de..2b63d99 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown-list</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-dropdown-list.html">
 
@@ -66,7 +68,7 @@
       assert.equal(element._computeMobileText(item), item.mobileText);
     });
 
-    test('options are selected and laid out correctly', () => {
+    test('options are selected and laid out correctly', done => {
       element.value = 2;
       element.items = [
         {
@@ -92,77 +94,79 @@
       ];
       assert.equal(element.$$('paper-listbox').selected, element.value);
       assert.equal(element.text, 'Button Text 2');
-      flushAsynchronousOperations();
-      const items = Polymer.dom(element.root).querySelectorAll('paper-item');
-      const mobileItems = Polymer.dom(element.root).querySelectorAll('option');
-      assert.equal(items.length, 3);
-      assert.equal(mobileItems.length, 3);
+      flush(() => {
+        const items = Polymer.dom(element.root).querySelectorAll('paper-item');
+        const mobileItems = Polymer.dom(element.root).querySelectorAll('option');
+        assert.equal(items.length, 3);
+        assert.equal(mobileItems.length, 3);
 
-      // First Item
-      // The first item should be disabled, has no bottom text, and no date.
-      assert.isFalse(!!items[0].disabled);
-      assert.isFalse(mobileItems[0].disabled);
-      assert.isFalse(items[0].classList.contains('iron-selected'));
-      assert.isFalse(mobileItems[0].selected);
+        // First Item
+        // The first item should be disabled, has no bottom text, and no date.
+        assert.isFalse(!!items[0].disabled);
+        assert.isFalse(mobileItems[0].disabled);
+        assert.isFalse(items[0].classList.contains('iron-selected'));
+        assert.isFalse(mobileItems[0].selected);
 
-      assert.isNotOk(Polymer.dom(items[0]).querySelector('gr-date-formatter'));
-      assert.isNotOk(Polymer.dom(items[0]).querySelector('.bottomContent'));
-      assert.equal(items[0].value, element.items[0].value);
-      assert.equal(mobileItems[0].value, element.items[0].value);
-      assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
-          .innerText, element.items[0].text);
+        assert.isNotOk(Polymer.dom(items[0]).querySelector('gr-date-formatter'));
+        assert.isNotOk(Polymer.dom(items[0]).querySelector('.bottomContent'));
+        assert.equal(items[0].value, element.items[0].value);
+        assert.equal(mobileItems[0].value, element.items[0].value);
+        assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
+            .innerText, element.items[0].text);
 
-      // Since no mobile specific text, it should fall back to text.
-      assert.equal(mobileItems[0].text, element.items[0].text);
+        // Since no mobile specific text, it should fall back to text.
+        assert.equal(mobileItems[0].text, element.items[0].text);
 
 
-      // Second Item
-      // The second item should have top text, bottom text, and no date.
-      assert.isFalse(!!items[1].disabled);
-      assert.isFalse(mobileItems[1].disabled);
-      assert.isTrue(items[1].classList.contains('iron-selected'));
-      assert.isTrue(mobileItems[1].selected);
+        // Second Item
+        // The second item should have top text, bottom text, and no date.
+        assert.isFalse(!!items[1].disabled);
+        assert.isFalse(mobileItems[1].disabled);
+        assert.isTrue(items[1].classList.contains('iron-selected'));
+        assert.isTrue(mobileItems[1].selected);
 
-      assert.isNotOk(Polymer.dom(items[1]).querySelector('gr-date-formatter'));
-      assert.isOk(Polymer.dom(items[1]).querySelector('.bottomContent'));
-      assert.equal(items[1].value, element.items[1].value);
-      assert.equal(mobileItems[1].value, element.items[1].value);
-      assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
-          .innerText, element.items[1].text);
+        assert.isNotOk(Polymer.dom(items[1]).querySelector('gr-date-formatter'));
+        assert.isOk(Polymer.dom(items[1]).querySelector('.bottomContent'));
+        assert.equal(items[1].value, element.items[1].value);
+        assert.equal(mobileItems[1].value, element.items[1].value);
+        assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
+            .innerText, element.items[1].text);
 
-      // Since there is mobile specific text, it should that.
-      assert.equal(mobileItems[1].text, element.items[1].mobileText);
+        // Since there is mobile specific text, it should that.
+        assert.equal(mobileItems[1].text, element.items[1].mobileText);
 
-      // Since this item is selected, and it has triggerText defined, that
-      // should be used.
-      assert.equal(element.text, element.items[1].triggerText);
+        // Since this item is selected, and it has triggerText defined, that
+        // should be used.
+        assert.equal(element.text, element.items[1].triggerText);
 
-      // Third item
-      // The third item should be disabled, and have a date, and bottom content.
-      assert.isTrue(!!items[2].disabled);
-      assert.isTrue(mobileItems[2].disabled);
-      assert.isFalse(items[2].classList.contains('iron-selected'));
-      assert.isFalse(mobileItems[2].selected);
+        // Third item
+        // The third item should be disabled, and have a date, and bottom content.
+        assert.isTrue(!!items[2].disabled);
+        assert.isTrue(mobileItems[2].disabled);
+        assert.isFalse(items[2].classList.contains('iron-selected'));
+        assert.isFalse(mobileItems[2].selected);
 
-      assert.isOk(Polymer.dom(items[2]).querySelector('gr-date-formatter'));
-      assert.isOk(Polymer.dom(items[2]).querySelector('.bottomContent'));
-      assert.equal(items[2].value, element.items[2].value);
-      assert.equal(mobileItems[2].value, element.items[2].value);
-      assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
-          .innerText, element.items[2].text);
+        assert.isOk(Polymer.dom(items[2]).querySelector('gr-date-formatter'));
+        assert.isOk(Polymer.dom(items[2]).querySelector('.bottomContent'));
+        assert.equal(items[2].value, element.items[2].value);
+        assert.equal(mobileItems[2].value, element.items[2].value);
+        assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
+            .innerText, element.items[2].text);
 
-      // Since there is mobile specific text, it should that.
-      assert.equal(mobileItems[2].text, element.items[2].mobileText);
+        // Since there is mobile specific text, it should that.
+        assert.equal(mobileItems[2].text, element.items[2].mobileText);
 
-      // Select a new item.
-      MockInteractions.tap(items[0]);
-      flushAsynchronousOperations();
-      assert.equal(element.value, 1);
-      assert.isTrue(items[0].classList.contains('iron-selected'));
-      assert.isTrue(mobileItems[0].selected);
+        // Select a new item.
+        MockInteractions.tap(items[0]);
+        flushAsynchronousOperations();
+        assert.equal(element.value, 1);
+        assert.isTrue(items[0].classList.contains('iron-selected'));
+        assert.isTrue(mobileItems[0].selected);
 
-      // Since no triggerText, the fallback is used.
-      assert.equal(element.text, element.items[0].text);
+        // Since no triggerText, the fallback is used.
+        assert.equal(element.text, element.items[0].text);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index 373e77d..d76721f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -17,8 +17,8 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -63,7 +63,7 @@
       li .itemAction {
         cursor: pointer;
         display: block;
-        padding: .85em 1em;
+        padding: var(--spacing-m) var(--spacing-l);
       }
       li .itemAction {
         @apply --gr-dropdown-item;
@@ -90,7 +90,7 @@
       }
       .topContent {
         display: block;
-        padding: .85em 1em;
+        padding: var(--spacing-m) var(--spacing-l);
         @apply --gr-dropdown-item;
       }
       .bold-text {
@@ -101,7 +101,7 @@
         link="[[link]]"
         class="dropdown-trigger" id="trigger"
         down-arrow="[[downArrow]]"
-        on-tap="_dropdownTriggerTapHandler">
+        on-click="_dropdownTriggerTapHandler">
       <slot></slot>
     </gr-button>
     <iron-dropdown id="dropdown"
@@ -139,7 +139,7 @@
                 <span
                     class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
                     data-id$="[[link.id]]"
-                    on-tap="_handleItemTap"
+                    on-click="_handleItemTap"
                     hidden$="[[link.url]]"
                     tabindex="-1">[[link.name]]</span>
                 <a
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index b4b7087..11825c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-dropdown',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a non-link dropdown item with the given ID is tapped.
@@ -143,10 +142,10 @@
       e.preventDefault();
       e.stopPropagation();
       if (this.$.dropdown.opened) {
-        // TODO(kaspern): This solution will not work in Shadow DOM, and
-        // is not particularly robust in general. Find a better solution
-        // when page.js has been abstracted away from components.
-        const el = this.$.cursor.target.querySelector(':not([hidden])');
+        // TODO(milutin): This solution is not particularly robust in general.
+        // Since gr-tooltip-content click on shadow dom is not propagated down,
+        // we have to target `a` inside it.
+        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
         if (el) { el.click(); }
       } else {
         this._open();
@@ -182,8 +181,8 @@
      */
     _open() {
       this.$.dropdown.open();
+      this._resetCursorStops();
       this.$.cursor.setCursorAtIndex(0);
-      Polymer.dom.flush();
       this.$.cursor.target.focus();
     },
 
@@ -295,10 +294,11 @@
      * Recompute the stops for the dropdown item cursor.
      */
     _resetCursorStops() {
-      Polymer.dom.flush();
-      // Polymer2: querySelectorAll returns NodeList instead of Array.
-      this._listElements = Array.from(
-          Polymer.dom(this.root).querySelectorAll('li'));
+      if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
+        Polymer.dom.flush();
+        this._listElements = Array.from(
+            Polymer.dom(this.root).querySelectorAll('li'));
+      }
     },
 
     _computeHasTooltip(tooltip) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 7bb4dce..295f746 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-dropdown.html">
 
@@ -194,7 +196,7 @@
         MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
         assert.isTrue(element.$.dropdown.opened);
 
-        const el = element.$.cursor.target.querySelector(':not([hidden])');
+        const el = element.$.cursor.target.querySelector(':not([hidden]) a');
         const stub = sandbox.stub(el, 'click');
         MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
         assert.isTrue(stub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index 6cd87f5..45ddfc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-storage/gr-storage.html">
+<link rel="import" href="../gr-button/gr-button.html">
 
 <dom-module id="gr-editable-content">
   <template>
@@ -29,10 +31,18 @@
       :host([disabled]) iron-autogrow-textarea {
         opacity: .5;
       }
-      iron-autogrow-textarea {
+      .viewer {
+        background-color: var(--view-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-m);
+      }
+      .editor iron-autogrow-textarea {
+        background-color: var(--view-background-color);
         width: 100%;
 
         --iron-autogrow-textarea: {
+          padding: var(--spacing-m);
           box-sizing: border-box;
           overflow-y: hidden;
           white-space: pre;
@@ -43,7 +53,7 @@
         justify-content: space-between;
       }
     </style>
-    <div hidden$="[[editing]]">
+    <div class="viewer" hidden$="[[editing]]">
       <slot></slot>
     </div>
     <div class="editor" hidden$="[[!editing]]">
@@ -53,10 +63,10 @@
           disabled="[[disabled]]"></iron-autogrow-textarea>
       <div class="editButtons">
         <gr-button primary
-            on-tap="_handleSave"
+            on-click="_handleSave"
             disabled="[[_saveDisabled]]">Save</gr-button>
         <gr-button
-            on-tap="_handleCancel"
+            on-click="_handleCancel"
             disabled="[[disabled]]">Cancel</gr-button>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 5463564..ee41103 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-editable-content',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the save button is pressed.
@@ -71,6 +70,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     focusTextarea() {
       this.$$('iron-autogrow-textarea').textarea.focus();
     },
@@ -117,8 +120,11 @@
             this.$.storage.getEditableContentItem(this.storageKey);
         if (storedContent && storedContent.message) {
           content = storedContent.message;
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: RESTORED_MESSAGE},
+            bubbles: true,
+            composed: true,
+          }));
         }
       }
       if (!content) {
@@ -132,6 +138,15 @@
     },
 
     _computeSaveDisabled(disabled, content, newContent) {
+      // Polymer 2: check for undefined
+      if ([
+        disabled,
+        content,
+        newContent,
+      ].some(arg => arg === undefined)) {
+        return true;
+      }
+
       return disabled || !newContent || content === newContent;
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index ca500cb..ee5adb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-content</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-content.html">
 
@@ -47,6 +49,7 @@
     teardown(() => { sandbox.restore(); });
 
     test('save event', done => {
+      element.content = '';
       element._newContent = 'foo';
       element.addEventListener('editable-content-save', e => {
         assert.equal(e.detail.content, 'foo');
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index ddc35bf..78465e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -14,12 +14,14 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/paper-input/paper-input.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../gr-button/gr-button.html">
 
 <dom-module id="gr-editable-label">
   <template>
@@ -35,13 +37,9 @@
       label {
         width: 100%;
       }
-      input {
-        font: inherit;
-      }
       label {
         color: var(--deemphasized-text-color);
         display: inline-block;
-        font-weight: var(--font-weight-bold);
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
@@ -56,17 +54,17 @@
       }
       .inputContainer {
         background-color: var(--dialog-background-color);
-        padding: .8em;
+        padding: var(--spacing-m);
         @apply --input-style;
       }
       .buttons {
         display: flex;
         justify-content: flex-end;
-        padding-top: 1.2em;
+        padding-top: var(--spacing-l);
         width: 100%;
       }
       .buttons gr-button {
-        margin-left: .5em;
+        margin-left: var(--spacing-m);
       }
       paper-input {
         --paper-input-container: {
@@ -74,7 +72,7 @@
           min-width: 15em;
         }
         --paper-input-container-input: {
-          font-size: var(--font-size-normal);
+          font-size: inherit;
         }
         --paper-input-container-focus-color: var(--link-color);
       }
@@ -82,7 +80,7 @@
       <label
           class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
           title$="[[_computeLabel(value, placeholder)]]"
-          on-tap="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
+          on-click="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
       <iron-dropdown id="dropdown"
           vertical-align="auto"
           horizontal-align="auto"
@@ -97,8 +95,8 @@
                 maxlength="[[maxLength]]"
                 value="{{_inputText}}"></paper-input>
             <div class="buttons">
-              <gr-button link id="cancelBtn" on-tap="_cancel">cancel</gr-button>
-              <gr-button link id="saveBtn" on-tap="_save">save</gr-button>
+              <gr-button link id="cancelBtn" on-click="_cancel">cancel</gr-button>
+              <gr-button link id="saveBtn" on-click="_save">save</gr-button>
             </div>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index f23afea..4485551 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-editable-label',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the value is changed.
@@ -68,6 +67,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -94,15 +94,15 @@
     _showDropdown() {
       if (this.readOnly || this.editing) { return; }
       return this._open().then(() => {
-        this.$.input.$.input.focus();
+        this._nativeInput.focus();
         if (!this.$.input.value) { return; }
-        this.$.input.$.input.setSelectionRange(0, this.$.input.value.length);
+        this._nativeInput.setSelectionRange(0, this.$.input.value.length);
       });
     },
 
     open() {
       return this._open().then(() => {
-        this.$.input.$.input.focus();
+        this._nativeInput.focus();
       });
     },
 
@@ -154,29 +154,25 @@
       this._inputText = this.value;
     },
 
-    /**
-     * @suppress {checkTypes}
-     * Closure doesn't think 'e' is an Event.
-     * TODO(beckysiegel) figure out why.
-     */
+    get _nativeInput() {
+      // In Polymer 2, the namespace of nativeInput
+      // changed from input to nativeInput
+      return this.$.input.$.nativeInput || this.$.input.$.input;
+    },
+
     _handleEnter(e) {
       e = this.getKeyboardEvent(e);
       const target = Polymer.dom(e).rootTarget;
-      if (target === this.$.input.$.input) {
+      if (target === this._nativeInput) {
         e.preventDefault();
         this._save();
       }
     },
 
-    /**
-     * @suppress {checkTypes}
-     * Closure doesn't think 'e' is an Event.
-     * TODO(beckysiegel) figure out why.
-     */
     _handleEsc(e) {
       e = this.getKeyboardEvent(e);
       const target = Polymer.dom(e).rootTarget;
-      if (target === this.$.input.$.input) {
+      if (target === this._nativeInput) {
         e.preventDefault();
         this._cancel();
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 6815173..7ff0a14 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-label.html">
 
@@ -59,13 +61,17 @@
     let label;
     let sandbox;
 
-    setup(() => {
+    setup(done => {
       element = fixture('basic');
       elementNoPlaceholder = fixture('no-placeholder');
 
-      input = element.$.input.$.input;
       label = element.$$('label');
       sandbox = sinon.sandbox.create();
+      flush(() => {
+        // In Polymer 2 inputElement isn't nativeInput anymore
+        input = element.$.input.$.nativeInput || element.$.input.inputElement;
+        done();
+      });
     });
 
     teardown(() => {
@@ -77,7 +83,7 @@
       assert.isFalse(element.$.dropdown.opened);
       assert.isTrue(label.classList.contains('editable'));
       assert.equal(label.textContent, 'value text');
-      const focusSpy = sandbox.spy(element.$.input.$.input, 'focus');
+      const focusSpy = sandbox.spy(input, 'focus');
       const showSpy = sandbox.spy(element, '_showDropdown');
 
       MockInteractions.tap(label);
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
index 674ff97..226092f 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
@@ -15,13 +15,14 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-fixed-panel">
   <template>
     <style include="shared-styles">
       :host {
+        box-sizing: border-box;
         display: block;
         min-height: var(--header-height);
         position: relative;
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index 87cd4b4..2c32709 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-fixed-panel',
-    _legacyUndefinedCheck: true,
 
     properties: {
       floatingDisabled: Boolean,
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
index 9eac7f7..75e9901 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-fixed-panel</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-fixed-panel.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
index 3995595..a98952c 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -29,7 +29,7 @@
       ul,
       blockquote,
       gr-linked-text.pre {
-        margin: 0 0 .8em 0;
+        margin: 0 0 var(--spacing-m) 0;
       }
       p,
       ul,
@@ -44,14 +44,16 @@
       }
       blockquote {
         border-left: 1px solid #aaa;
-        padding: 0 .7em;
+        padding: 0 var(--spacing-m);
       }
       li {
         list-style-type: disc;
-        margin-left: 1.4em;
+        margin-left: var(--spacing-xl);
       }
       gr-linked-text.pre {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-code);
       }
 
     </style>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index d84c38e..feae173 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-formatted-text',
-    _legacyUndefinedCheck: true,
 
     properties: {
       content: {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index ad036c5..801190a 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-formatted-text.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
index 7e3246f..c77ea81 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -34,10 +34,10 @@
         visibility: visible;
         opacity: 1;
       }
-      :host ::content #hovercard {
+      #hovercard {
         background: var(--dialog-background-color);
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
-        padding: 1em;
+        padding: var(--spacing-l);
       }
     </style>
     <div id="hovercard" role="tooltip" tabindex="-1">
@@ -46,4 +46,4 @@
   </template>
   <script src="../../../scripts/rootElement.js"></script>
   <script src="gr-hovercard.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index 761ef5b..3a43191 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -27,7 +27,6 @@
 
   Polymer({
     is: 'gr-hovercard',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -98,7 +97,7 @@
       this.listen(this._target, 'focus', 'show');
       this.listen(this._target, 'mouseleave', 'hide');
       this.listen(this._target, 'blur', 'hide');
-      this.listen(this._target, 'tap', 'hide');
+      this.listen(this._target, 'click', 'hide');
     },
 
     ready() {
@@ -119,7 +118,7 @@
       this.unlisten(this._target, 'focus', 'show');
       this.unlisten(this._target, 'mouseleave', 'hide');
       this.unlisten(this._target, 'blur', 'hide');
-      this.unlisten(this._target, 'tap', 'hide');
+      this.unlisten(this._target, 'click', 'hide');
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
index e3e252f..8e79f65 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-hovercard</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-hovercard.html">
 
@@ -39,7 +41,8 @@
   suite('gr-hovercard tests', () => {
     let element;
     let sandbox;
-    const TRANSITION_TIME = 200;
+    // For css animations
+    const TRANSITION_TIME = 500;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 1f885af..7bd6f48 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -14,8 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../bower_components/iron-iconset-svg/iron-iconset-svg.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="/bower_components/iron-iconset-svg/iron-iconset-svg.html">
 
 <iron-iconset-svg name="gr-icons" size="24">
   <svg>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
index 2581ff9..708e5b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
@@ -45,26 +45,27 @@
    *
    * @param {number} offset The char offset where the update starts.
    * @param {number} length The number of chars that the update covers.
-   * @param {string} cssClass The name of a CSS class created using Gerrit.css.
+   * @param {GrStyleObject} styleObject The style object for the range.
    * @param {string} side The side of the update. ('left' or 'right')
    */
   GrAnnotationActionsContext.prototype.annotateRange = function(
-      offset, length, cssClass, side) {
+      offset, length, styleObject, side) {
     if (this._contentEl && this._contentEl.getAttribute('data-side') == side) {
-      GrAnnotation.annotateElement(this._contentEl, offset, length, cssClass);
+      GrAnnotation.annotateElement(this._contentEl, offset, length,
+          styleObject.getClassName(this._contentEl));
     }
   };
 
   /**
    * Method to add a CSS class to the line number TD element.
    *
-   * @param {string} cssClass The name of a CSS class created using Gerrit.css.
+   * @param {GrStyleObject} styleObject The style object for the range.
    * @param {string} side The side of the update. ('left' or 'right')
    */
   GrAnnotationActionsContext.prototype.annotateLineNumber = function(
-      cssClass, side) {
+      styleObject, side) {
     if (this._lineNumberEl && this._lineNumberEl.classList.contains(side)) {
-      this._lineNumberEl.classList.add(cssClass);
+      styleObject.apply(this._lineNumberEl);
     }
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
index 03c8c5e..3223636 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-context</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
@@ -40,9 +42,13 @@
     let sandbox;
     let el;
     let lineNumberEl;
+    let plugin;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+
       const str = 'lorem ipsum blah blah';
       const line = {text: str};
       el = document.createElement('div');
@@ -50,6 +56,7 @@
       el.setAttribute('data-side', 'right');
       lineNumberEl = document.createElement('td');
       lineNumberEl.classList.add('right');
+      document.body.appendChild(el);
       instance = new GrAnnotationActionsContext(
           el, lineNumberEl, line, 'dummy/path', '123', '1');
     });
@@ -62,32 +69,34 @@
       annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
       const start = 0;
       const end = 100;
-      const cssClass = Gerrit.css('background-color: #000000');
+      const cssStyleObject = plugin.styles().css('background-color: #000000');
 
       // Assert annotateElement is not called when side is different.
-      instance.annotateRange(start, end, cssClass, 'left');
+      instance.annotateRange(start, end, cssStyleObject, 'left');
       assert.equal(annotateElementSpy.callCount, 0);
 
       // Assert annotateElement is called once when side is the same.
-      instance.annotateRange(start, end, cssClass, 'right');
+      instance.annotateRange(start, end, cssStyleObject, 'right');
       assert.equal(annotateElementSpy.callCount, 1);
       const args = annotateElementSpy.getCalls()[0].args;
       assert.equal(args[0], el);
       assert.equal(args[1], start);
       assert.equal(args[2], end);
-      assert.equal(args[3], cssClass);
+      assert.equal(args[3], cssStyleObject.getClassName(el));
     });
 
     test('test annotateLineNumber', () => {
-      const cssClass = Gerrit.css('background-color: #000000');
+      const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+      const className = cssStyleObject.getClassName(lineNumberEl);
 
       // Assert that css class is *not* applied when side is different.
-      instance.annotateLineNumber(cssClass, 'left');
-      assert.isFalse(lineNumberEl.classList.contains(cssClass));
+      instance.annotateLineNumber(cssStyleObject, 'left');
+      assert.isFalse(lineNumberEl.classList.contains(className));
 
       // Assert that css class is applied when side is the same.
-      instance.annotateLineNumber(cssClass, 'right');
-      assert.isTrue(lineNumberEl.classList.contains(cssClass));
+      instance.annotateLineNumber(cssStyleObject, 'right');
+      assert.isTrue(lineNumberEl.classList.contains(className));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index 3f91a82..4ba9820 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -67,10 +67,8 @@
    * providers are not supported. A second call will just overwrite the
    * provider of the first call.
    *
-   * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
-   *
    * @param {function(changeNum, path, basePatchNum, patchNum):
-   * !Promise<!Array<Object>>} coverageProvider
+   * !Promise<!Array<!Gerrit.CoverageRange>>} coverageProvider
    * @return {GrAnnotationActionsInterface}
    */
   GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
index bf7c2cb..987b551 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-js-api-js-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../change/gr-change-actions/gr-change-actions.html">
 
@@ -28,7 +30,9 @@
   <template>
     <span hidden id="annotation-span">
       <label for="annotation-checkbox" id="annotation-label"></label>
-      <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+      <iron-input type="checkbox" disabled>
+        <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+      </iron-input>
     </span>
   </template>
 </test-fixture>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
new file mode 100644
index 0000000..2925736
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -0,0 +1,112 @@
+/**
+ * @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 PRELOADED_PROTOCOL = 'preloaded:';
+  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
+  let _restAPI;
+  function getRestAPI() {
+    if (!_restAPI) {
+      _restAPI = document.createElement('gr-rest-api-interface');
+    }
+    return _restAPI;
+  }
+
+  function getBaseUrl() {
+    return Gerrit.BaseUrlBehavior.getBaseUrl();
+  }
+
+  /**
+   * Retrieves the name of the plugin base on the url.
+   *
+   * @param {string|URL} url
+   */
+  function getPluginNameFromUrl(url) {
+    if (!(url instanceof URL)) {
+      try {
+        url = new URL(url);
+      } catch (e) {
+        console.warn(e);
+        return null;
+      }
+    }
+    if (url.protocol === PRELOADED_PROTOCOL) {
+      return url.pathname;
+    }
+    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    const pathname = url.pathname.replace(base, '');
+    // Site theme is server from predefined path.
+    if (pathname === '/static/gerrit-theme.html') {
+      return 'gerrit-theme';
+    } else if (!pathname.startsWith('/plugins')) {
+      console.warn('Plugin not being loaded from /plugins base path:',
+          url.href, '— Unable to determine name.');
+      return null;
+    }
+
+    // Pathname should normally look like this:
+    // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+    // Or, for app/samples:
+    // /plugins/PLUGINNAME.html
+    // TODO(taoalpha): guard with a regex
+    return pathname.split('/')[2].split('.')[0];
+  }
+
+  // TODO(taoalpha): to be deprecated.
+  function send(method, url, opt_callback, opt_payload) {
+    return getRestAPI().send(method, url, opt_payload).then(response => {
+      if (response.status < 200 || response.status >= 300) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      } else {
+        return getRestAPI().getResponseObject(response);
+      }
+    }).then(response => {
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  }
+
+
+  // TEST only methods / properties
+
+  function testOnly_resetInternalState() {
+    _restAPI = undefined;
+  }
+
+  window._apiUtils = {
+    getPluginNameFromUrl,
+    send,
+    getRestAPI,
+    getBaseUrl,
+    PRELOADED_PROTOCOL,
+    PLUGIN_LOADING_TIMEOUT_MS,
+
+    // TEST only methods
+    testOnly_resetInternalState,
+  };
+})(window);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
new file mode 100644
index 0000000..c407aa8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
@@ -0,0 +1,78 @@
+<!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-api-interface</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-js-api-interface.html">
+
+<script>void(0);</script>
+
+<script>
+
+  const PRELOADED_PROTOCOL = 'preloaded:';
+
+  suite('gr-api-utils tests', () => {
+    suite('test getPluginNameFromUrl', () => {
+      const {getPluginNameFromUrl} = window._apiUtils;
+
+      test('with empty string', () => {
+        assert.equal(getPluginNameFromUrl(''), null);
+      });
+
+      test('with invalid url', () => {
+        assert.equal(getPluginNameFromUrl('test'), null);
+      });
+
+      test('with random invalid url', () => {
+        assert.equal(getPluginNameFromUrl('http://example.com'), null);
+        assert.equal(
+            getPluginNameFromUrl('http://example.com/static/a.html'),
+            null
+        );
+      });
+
+      test('with valid urls', () => {
+        assert.equal(
+            getPluginNameFromUrl('http://example.com/plugins/a.html'),
+            'a'
+        );
+        assert.equal(
+            getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
+            'a'
+        );
+      });
+
+      test('with preloaded urls', () => {
+        assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
+      });
+
+      test('with gerrit-theme override', () => {
+        assert.equal(
+            getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
+            'gerrit-theme'
+        );
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index fef4fc9..7332877 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions-js-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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"/>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
@@ -52,17 +54,18 @@
 
     suite('early init', () => {
       setup(() => {
-        Gerrit._resetPlugins();
+        Gerrit._testOnly_resetPlugins();
         Gerrit.install(p => { plugin = p; }, '0.1',
             'http://test.com/plugins/testplugin/static/test.js');
         // Mimic all plugins loaded.
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
         changeActions = plugin.changeActions();
         element = fixture('basic');
       });
 
       teardown(() => {
         changeActions = null;
+        Gerrit._testOnly_resetPlugins();
       });
 
       test('does not throw', ()=> {
@@ -74,7 +77,7 @@
 
     suite('normal init', () => {
       setup(() => {
-        Gerrit._resetPlugins();
+        Gerrit._testOnly_resetPlugins();
         element = fixture('basic');
         sinon.stub(element, '_editStatusChanged');
         element.change = {};
@@ -83,11 +86,12 @@
             'http://test.com/plugins/testplugin/static/test.js');
         changeActions = plugin.changeActions();
         // Mimic all plugins loaded.
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
       });
 
       teardown(() => {
         changeActions = null;
+        Gerrit._testOnly_resetPlugins();
       });
 
       test('property existence', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 278f95a..842a2fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-reply-js-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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"/>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
new file mode 100644
index 0000000..73a2480
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
@@ -0,0 +1,196 @@
+/**
+ * @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.
+ */
+
+/**
+ * This defines the Gerrit instance. All methods directly attached to Gerrit
+ * should be defined or linked here.
+ */
+
+(function(window) {
+  'use strict';
+
+  // Import utils methods
+  const {
+    send,
+    getRestAPI,
+  } = window._apiUtils;
+
+  /**
+   * Trigger the preinstalls for bundled plugins.
+   * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
+   */
+  function flushPreinstalls() {
+    if (window.Gerrit.flushPreinstalls) {
+      window.Gerrit.flushPreinstalls();
+    }
+  }
+  flushPreinstalls();
+
+  window.Gerrit = window.Gerrit || {};
+  const Gerrit = window.Gerrit;
+  Gerrit._pluginLoader = new PluginLoader();
+
+  Gerrit._endpoints = new GrPluginEndpoints();
+
+  // Provide reset plugins function to clear installed plugins between tests.
+  const app = document.querySelector('#app');
+  if (!app) {
+    // No gr-app found (running tests)
+    const {
+      testOnly_resetInternalState,
+    } = window._apiUtils;
+    Gerrit._testOnly_installPreloadedPlugins = (...args) => Gerrit._pluginLoader
+        .installPreloadedPlugins(...args);
+    Gerrit._testOnly_flushPreinstalls = flushPreinstalls;
+    Gerrit._testOnly_resetPlugins = () => {
+      testOnly_resetInternalState();
+      Gerrit._endpoints = new GrPluginEndpoints();
+      Gerrit._pluginLoader = new PluginLoader();
+    };
+  }
+
+  /**
+   * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
+   * the documentation how to replace it accordingly.
+   */
+  Gerrit.css = function(rulesStr) {
+    console.warn('Gerrit.css(rulesStr) is deprecated!',
+        'Use plugin.styles().css(rulesStr)');
+    if (!Gerrit._customStyleSheet) {
+      const styleEl = document.createElement('style');
+      document.head.appendChild(styleEl);
+      Gerrit._customStyleSheet = styleEl.sheet;
+    }
+
+    const name = '__pg_js_api_class_' +
+        Gerrit._customStyleSheet.cssRules.length;
+    Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
+    return name;
+  };
+
+  Gerrit.install = function(callback, opt_version, opt_src) {
+    Gerrit._pluginLoader.install(callback, opt_version, opt_src);
+  };
+
+  Gerrit.getLoggedIn = function() {
+    console.warn('Gerrit.getLoggedIn() is deprecated! ' +
+        'Use plugin.restApi().getLoggedIn()');
+    return document.createElement('gr-rest-api-interface').getLoggedIn();
+  };
+
+  Gerrit.get = function(url, callback) {
+    console.warn('.get() is deprecated! Use plugin.restApi().get()');
+    send('GET', url, callback);
+  };
+
+  Gerrit.post = function(url, payload, callback) {
+    console.warn('.post() is deprecated! Use plugin.restApi().post()');
+    send('POST', url, callback, payload);
+  };
+
+  Gerrit.put = function(url, payload, callback) {
+    console.warn('.put() is deprecated! Use plugin.restApi().put()');
+    send('PUT', url, callback, payload);
+  };
+
+  Gerrit.delete = function(url, opt_callback) {
+    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+    return getRestAPI().send('DELETE', url).then(response => {
+      if (response.status !== 204) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      }
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  };
+
+  Gerrit.awaitPluginsLoaded = function() {
+    return Gerrit._pluginLoader.awaitPluginsLoaded();
+  };
+
+  // TODO(taoalpha): consider removing these proxy methods
+  // and using _pluginLoader directly
+
+  Gerrit._loadPlugins = function(plugins, opt_option) {
+    Gerrit._pluginLoader.loadPlugins(plugins, opt_option);
+  };
+
+  Gerrit._arePluginsLoaded = function() {
+    return Gerrit._pluginLoader.arePluginsLoaded;
+  };
+
+  Gerrit._isPluginPreloaded = function(url) {
+    return Gerrit._pluginLoader.isPluginPreloaded(url);
+  };
+
+  Gerrit._isPluginEnabled = function(pathOrUrl) {
+    return Gerrit._pluginLoader.isPluginEnabled(pathOrUrl);
+  };
+
+  Gerrit._isPluginLoaded = function(pathOrUrl) {
+    return Gerrit._pluginLoader.isPluginLoaded(pathOrUrl);
+  };
+
+  // Preloaded plugins should be installed after Gerrit.install() is set,
+  // since plugin preloader substitutes Gerrit.install() temporarily.
+  Gerrit._pluginLoader.installPreloadedPlugins();
+
+  // TODO(taoalpha): List all internal supported event names.
+  // Also convert this to inherited class once we move Gerrit to class.
+  Gerrit._eventEmitter = new EventEmitter();
+  ['addListener',
+    'dispatch',
+    'emit',
+    'off',
+    'on',
+    'once',
+    'removeAllListeners',
+    'removeListener',
+  ].forEach(method => {
+    /**
+     * Enabling EventEmitter interface on Gerrit.
+     *
+     * This will enable to signal across different parts of js code without relying on DOM,
+     * including core to core, plugin to plugin and also core to plugin.
+     *
+     * @example
+     *
+     * // Emit this event from pluginA
+     * Gerrit.install(pluginA => {
+     *   fetch("some-api").then(() => {
+     *     Gerrit.on("your-special-event", {plugin: pluginA});
+     *   });
+     * });
+     *
+     * // Listen on your-special-event from pluignB
+     * Gerrit.install(pluginB => {
+     *   Gerrit.on("your-special-event", ({plugin}) => {
+     *     // do something, plugin is pluginA
+     *   });
+     * });
+     */
+    Gerrit[method] = Gerrit._eventEmitter[method].bind(Gerrit._eventEmitter);
+  });
+})(window);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
new file mode 100644
index 0000000..e81b8aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
@@ -0,0 +1,100 @@
+<!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-api-interface</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-js-api-interface.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-js-api-interface></gr-js-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-gerrit tests', () => {
+    let element;
+    let sandbox;
+    let sendStub;
+
+    setup(() => {
+      this.clock = sinon.useFakeTimers();
+      sandbox = sinon.sandbox.create();
+      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+      stub('gr-rest-api-interface', {
+        getAccount() {
+          return Promise.resolve({name: 'Judy Hopps'});
+        },
+        send(...args) {
+          return sendStub(...args);
+        },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      this.clock.restore();
+      sandbox.restore();
+      element._removeEventCallbacks();
+      Gerrit._testOnly_resetPlugins();
+    });
+
+    suite('proxy methods', () => {
+      test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
+        const stubFn = sandbox.stub();
+        sandbox.stub(
+            Gerrit._pluginLoader,
+            'isPluginEnabled',
+            (...args) => stubFn(...args)
+        );
+        Gerrit._isPluginEnabled('test_plugin');
+        assert.isTrue(stubFn.calledWith('test_plugin'));
+      });
+
+      test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
+        const stubFn = sandbox.stub();
+        sandbox.stub(
+            Gerrit._pluginLoader,
+            'isPluginLoaded',
+            (...args) => stubFn(...args)
+        );
+        Gerrit._isPluginLoaded('test_plugin');
+        assert.isTrue(stubFn.calledWith('test_plugin'));
+      });
+
+      test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
+        const stubFn = sandbox.stub();
+        sandbox.stub(
+            Gerrit._pluginLoader,
+            'isPluginPreloaded',
+            (...args) => stubFn(...args)
+        );
+        Gerrit._isPluginPreloaded('test_plugin');
+        assert.isTrue(stubFn.calledWith('test_plugin'));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index d95fd0a..72fc3d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
@@ -26,10 +26,19 @@
 <link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
 <link rel="import" href="../../plugins/gr-repo-api/gr-repo-api.html">
 <link rel="import" href="../../plugins/gr-settings-api/gr-settings-api.html">
+<link rel="import" href="../../plugins/gr-styles-api/gr-styles-api.html">
 <link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
+  <!--
+    Note: the order matters as files depend on each other.
+    1. gr-api-utils will be used in multiple files below.
+    2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
+      also gr-plugin-endpoints
+    3. gr-public-js-api depends on gr-plugin-rest-api
+  -->
+  <script src="gr-api-utils.js"></script>
   <script src="../gr-event-interface/gr-event-interface.js"></script>
   <script src="gr-annotation-actions-context.js"></script>
   <script src="gr-annotation-actions-js-api.js"></script>
@@ -40,4 +49,6 @@
   <script src="gr-plugin-action-context.js"></script>
   <script src="gr-plugin-rest-api.js"></script>
   <script src="gr-public-js-api.js"></script>
+  <script src="gr-plugin-loader.js"></script>
+  <script src="gr-gerrit.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index ad318d7..2c00c89 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -17,11 +17,13 @@
 (function() {
   'use strict';
 
+  // Note: for new events, naming convention should be: `a-b`
   const EventType = {
     HISTORY: 'history',
     LABEL_CHANGE: 'labelchange',
     SHOW_CHANGE: 'showchange',
     SUBMIT_CHANGE: 'submitchange',
+    SHOW_REVISION_ACTIONS: 'show-revision-actions',
     COMMIT_MSG_EDIT: 'commitmsgedit',
     COMMENT: 'comment',
     REVERT: 'revert',
@@ -38,7 +40,6 @@
 
   Polymer({
     is: 'gr-js-api-interface',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _elements: {
@@ -71,6 +72,9 @@
           case EventType.LABEL_CHANGE:
             this._handleLabelChange(detail);
             break;
+          case EventType.SHOW_REVISION_ACTIONS:
+            this._handleShowRevisionActions(detail);
+            break;
           case EventType.HIGHLIGHTJS_LOADED:
             this._handleHighlightjsLoaded(detail);
             break;
@@ -136,13 +140,16 @@
       //
       // This clone and getter can be removed after plugins migrate to use
       // info.mergeable.
-      const change = Object.assign({
+      //
+      // assign on getter with existing property will report error
+      // see Issue: 12286
+      const change = Object.assign({}, detail.change, {
         get mergeable() {
           console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
               'deprecated! Use info.mergeable instead.');
-          return detail.info.mergeable;
+          return detail.info && detail.info.mergeable;
         },
-      }, detail.change);
+      });
       const patchNum = detail.patchNum;
       const info = detail.info;
 
@@ -163,6 +170,22 @@
       }
     },
 
+    /**
+     * @param {!{change: !Object, revisionActions: !Object}} detail
+     */
+    _handleShowRevisionActions(detail) {
+      const registeredCallbacks = this._getEventCallbacks(
+          EventType.SHOW_REVISION_ACTIONS
+      );
+      for (const cb of registeredCallbacks) {
+        try {
+          cb(detail.revisionActions, detail.change);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+    },
+
     handleCommitMessage(change, msg) {
       for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
         try {
@@ -232,30 +255,13 @@
      * Retrieves coverage data possibly provided by a plugin.
      *
      * Will wait for plugins to be loaded. If multiple plugins offer a coverage
-     * provider, the first one is used. If no plugin offers a coverage provider,
-     * will resolve to [].
-     *
-     * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
-     *
-     * @param {string|number} changeNum
-     * @param {string} path
-     * @param {string|number} basePatchNum
-     * @param {string|number} patchNum
-     * @return {!Promise<!Array<Object>>}
+     * provider, the first one is returned. If no plugin offers a coverage provider,
+     * will resolve to null.
      */
-    getCoverageRanges(changeNum, path, basePatchNum, patchNum) {
-      return Gerrit.awaitPluginsLoaded().then(() => {
-        for (const annotationApi of
-          this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-          const provider = annotationApi.getCoverageProvider();
-          // Only one coverage provider makes sense. If there are more, then we
-          // simply ignore them.
-          if (provider) {
-            return provider(changeNum, path, basePatchNum, patchNum);
-          }
-        }
-        return [];
-      });
+    getCoverageAnnotationApi() {
+      return Gerrit.awaitPluginsLoaded()
+          .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
+              .find(api => api.getCoverageProvider()));
     },
 
     getAdminMenuLinks() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index f03aa09..14928e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-js-api-interface.html">
 
@@ -33,6 +35,7 @@
 </test-fixture>
 
 <script>
+  const {PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
   suite('gr-js-api-interface tests', () => {
     let element;
     let plugin;
@@ -46,6 +49,7 @@
     };
 
     setup(() => {
+      this.clock = sinon.useFakeTimers();
       sandbox = sinon.sandbox.create();
       getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
       sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
@@ -62,32 +66,16 @@
       errorStub = sandbox.stub(console, 'error');
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
     });
 
     teardown(() => {
+      this.clock.restore();
       sandbox.restore();
       element._removeEventCallbacks();
       plugin = null;
     });
 
-    test('reuse plugin for install calls', () => {
-      let otherPlugin;
-      Gerrit.install(p => { otherPlugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      assert.strictEqual(plugin, otherPlugin);
-    });
-
-    test('flushes preinstalls if provided', () => {
-      assert.doesNotThrow(() => {
-        Gerrit._flushPreinstalls();
-      });
-      window.Gerrit.flushPreinstalls = sandbox.stub();
-      Gerrit._flushPreinstalls();
-      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
-      delete window.Gerrit.flushPreinstalls;
-    });
-
     test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
@@ -203,18 +191,37 @@
           {change: testChange, patchNum: 1, info: {mergeable: false}});
     });
 
+    test('show-revision-actions event', done => {
+      const testChange = {
+        _number: 42,
+        revisions: {def: {_number: 2}, abc: {_number: 1}},
+      };
+      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+        assert.deepEqual(change, testChange);
+        assert.deepEqual(actions, {test: {}});
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
+          {change: testChange, revisionActions: {test: {}}});
+    });
+
     test('handleEvent awaits plugins load', done => {
       const testChange = {
         _number: 42,
         revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
       const spy = sandbox.spy();
-      Gerrit._setPluginsCount(1);
+      Gerrit._loadPlugins(['plugins/test.html']);
       plugin.on(element.EventType.SHOW_CHANGE, spy);
       element.handleEvent(element.EventType.SHOW_CHANGE,
           {change: testChange, patchNum: 1});
       assert.isFalse(spy.called);
-      Gerrit._setPluginsCount(0);
+
+      // Timeout on loading plugins
+      this.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
       flush(() => {
         assert.isTrue(spy.called);
         done();
@@ -311,90 +318,13 @@
       element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
     });
 
-    test('versioning', () => {
-      const callback = sandbox.spy();
-      Gerrit.install(callback, '0.0pre-alpha');
-      assert(callback.notCalled);
-    });
-
     test('getAccount', done => {
-      Gerrit.getLoggedIn().then(loggedIn => {
+      plugin.restApi().getLoggedIn().then(loggedIn => {
         assert.isTrue(loggedIn);
         done();
       });
     });
 
-    test('_setPluginsCount', done => {
-      stub('gr-reporting', {
-        pluginsLoaded() {
-          done();
-        },
-      });
-      Gerrit._setPluginsCount(0);
-    });
-
-    test('_arePluginsLoaded', () => {
-      assert.isTrue(Gerrit._arePluginsLoaded());
-      Gerrit._setPluginsCount(1);
-      assert.isFalse(Gerrit._arePluginsLoaded());
-      Gerrit._setPluginsCount(0);
-      assert.isTrue(Gerrit._arePluginsLoaded());
-    });
-
-    test('_pluginInstalled', () => {
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-      Gerrit._setPluginsPending(plugins);
-      Gerrit._pluginInstalled(plugins[0]);
-      Gerrit._pluginInstalled(plugins[1]);
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-    });
-
-    test('install calls _pluginInstalled', () => {
-      sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-
-      // testplugin has already been installed once (in setup).
-      assert.isFalse(Gerrit._pluginInstalled.called);
-
-      // testplugin2 plugin has not yet been installed.
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin2/static/test.js');
-      assert.isTrue(Gerrit._pluginInstalled.calledOnce);
-    });
-
-    test('plugin install errors mark plugins as loaded', () => {
-      Gerrit._setPluginsCount(1);
-      Gerrit.install(() => {}, '0.0pre-alpha');
-      return Gerrit.awaitPluginsLoaded();
-    });
-
-    test('multiple ui plugins per java plugin', () => {
-      const file1 = 'http://test.com/plugins/qaz/static/foo.nocache.js';
-      const file2 = 'http://test.com/plugins/qaz/static/bar.js';
-      Gerrit._setPluginsPending([file1, file2]);
-      Gerrit.install(() => {}, '0.1', file1);
-      Gerrit.install(() => {}, '0.1', file2);
-      return Gerrit.awaitPluginsLoaded();
-    });
-
-    test('plugin install errors shows toasts', () => {
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-      Gerrit._setPluginsCount(1);
-      Gerrit.install(() => {}, '0.0pre-alpha');
-      return Gerrit.awaitPluginsLoaded().then(() => {
-        assert.isTrue(alertStub.calledOnce);
-      });
-    });
-
     test('attributeHelper', () => {
       assert.isOk(plugin.attributeHelper());
     });
@@ -420,40 +350,12 @@
           element.EventType.ADMIN_MENU_LINKS);
     });
 
-    test('Gerrit._isPluginPreloaded', () => {
-      Gerrit._preloadedPlugins = {baz: ()=>{}};
-      assert.isFalse(Gerrit._isPluginPreloaded('plugins/foo/bar'));
-      assert.isFalse(Gerrit._isPluginPreloaded('http://a.com/42'));
-      assert.isTrue(Gerrit._isPluginPreloaded('preloaded:baz'));
-      Gerrit._preloadedPlugins = null;
-    });
-
-    test('preloaded plugins are installed', () => {
-      const installStub = sandbox.stub();
-      Gerrit._preloadedPlugins = {foo: installStub};
-      Gerrit._installPreloadedPlugins();
-      assert.isTrue(installStub.called);
-      const pluginApi = installStub.lastCall.args[0];
-      assert.strictEqual(pluginApi.getPluginName(), 'foo');
-    });
-
-    test('installing preloaded plugin', () => {
-      let plugin;
-      window.ASSETS_PATH = 'http://blips.com/chitz';
-      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
-      assert.strictEqual(plugin.getPluginName(), 'foo');
-      assert.strictEqual(plugin.url('/some/thing.html'),
-          'http://blips.com/chitz/plugins/foo/some/thing.html');
-      delete window.ASSETS_PATH;
-    });
-
     suite('test plugin with base url', () => {
       let baseUrlPlugin;
 
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
 
-        Gerrit._setPluginsCount(1);
         Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
             'http://test.com/r/plugins/baseurlplugin/static/test.js');
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
index ca87956..6da117f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-action-context</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-js-api-interface.html"/>
 
@@ -40,7 +42,6 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
-      Gerrit._setPluginsCount(1);
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       instance = new GrPluginActionContext(plugin);
@@ -140,12 +141,12 @@
         send: sendStub,
       });
       const errorStub = sandbox.stub();
-      document.addEventListener('network-error', errorStub);
+      document.addEventListener('show-alert', errorStub);
       instance.call();
       flush(() => {
         assert.isTrue(errorStub.calledOnce);
         assert.equal(errorStub.args[0][0].detail.message,
-            'Plugin network error: boom');
+            'Plugin network error: Error: boom');
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index b00b5ac..8ed7f14 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-endpoints</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-js-api-interface.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
new file mode 100644
index 0000000..201b683
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -0,0 +1,397 @@
+/**
+ * @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';
+
+  // Import utils methods
+  const {
+    PLUGIN_LOADING_TIMEOUT_MS,
+    PRELOADED_PROTOCOL,
+    getPluginNameFromUrl,
+    getBaseUrl,
+  } = window._apiUtils;
+
+  /**
+   * @enum {string}
+   */
+  const PluginState = {
+    /**
+     * State that indicates the plugin is pending to be loaded.
+     */
+    PENDING: 'PENDING',
+
+    /**
+     * State that indicates the plugin is already loaded.
+     */
+    LOADED: 'LOADED',
+
+    /**
+     * State that indicates the plugin is already loaded.
+     */
+    PRE_LOADED: 'PRE_LOADED',
+
+    /**
+     * State that indicates the plugin failed to load.
+     */
+    LOAD_FAILED: 'LOAD_FAILED',
+  };
+
+  // Prefix for any unrecognized plugin urls.
+  // Url should match following patterns:
+  // /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
+  // /plugins/PLUGINNAME.(js|html)
+  const UNKNOWN_PLUGIN_PREFIX = '__$$__';
+
+  // Current API version for Plugin,
+  // plugins with incompatible version will not be laoded.
+  const API_VERSION = '0.1';
+
+  /**
+   * PluginLoader, responsible for:
+   *
+   * Loading all plugins and handling errors etc.
+   * Recording plugin state.
+   * Reporting on plugin loading status.
+   * Retrieve plugin.
+   * Check plugin status and if all plugins loaded.
+   */
+  class PluginLoader {
+    constructor() {
+      this._pluginListLoaded = false;
+
+      /** @type {Map<string,PluginLoader.PluginObject>} */
+      this._plugins = new Map();
+
+      this._reporting = null;
+
+      // Promise that resolves when all plugins loaded
+      this._loadingPromise = null;
+
+      // Resolver to resolve _loadingPromise once all plugins loaded
+      this._loadingResolver = null;
+    }
+
+    _getReporting() {
+      if (!this._reporting) {
+        this._reporting = document.createElement('gr-reporting');
+      }
+      return this._reporting;
+    }
+
+    /**
+     * Use the plugin name or use the full url if not recognized.
+     *
+     * @see gr-api-utils#getPluginNameFromUrl
+     * @param {string|URL} url
+     */
+    _getPluginKeyFromUrl(url) {
+      return getPluginNameFromUrl(url) ||
+        `${UNKNOWN_PLUGIN_PREFIX}${url}`;
+    }
+
+    /**
+     * Load multiple plugins with certain options.
+     *
+     * @param {Array<string>} plugins
+     * @param {Object<string, PluginLoader.PluginOption>} opts
+     */
+    loadPlugins(plugins = [], opts = {}) {
+      this._pluginListLoaded = true;
+
+      plugins.forEach(path => {
+        const url = this._urlFor(path);
+        // Skip if preloaded, for bundling.
+        if (this.isPluginPreloaded(url)) return;
+
+        const pluginKey = this._getPluginKeyFromUrl(url);
+        // Skip if already installed.
+        if (this._plugins.has(pluginKey)) return;
+        this._plugins.set(pluginKey, {
+          name: pluginKey,
+          url,
+          state: PluginState.PENDING,
+          plugin: null,
+        });
+
+        if (this._isPathEndsWith(url, '.html')) {
+          this._importHtmlPlugin(url, opts && opts[path]);
+        } else if (this._isPathEndsWith(url, '.js')) {
+          this._loadJsPlugin(url);
+        } else {
+          this._failToLoad(`Unrecognized plugin url ${url}`, url);
+        }
+      });
+
+      this.awaitPluginsLoaded().then(() => {
+        console.info('Plugins loaded');
+        this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
+      });
+    }
+
+    _isPathEndsWith(url, suffix) {
+      if (!(url instanceof URL)) {
+        try {
+          url = new URL(url);
+        } catch (e) {
+          console.warn(e);
+          return false;
+        }
+      }
+
+      return url.pathname && url.pathname.endsWith(suffix);
+    }
+
+    _getAllInstalledPluginNames() {
+      const installedPlugins = [];
+      for (const plugin of this._plugins.values()) {
+        if (plugin.state === PluginState.LOADED) {
+          installedPlugins.push(plugin.name);
+        }
+      }
+      return installedPlugins;
+    }
+
+    install(callback, opt_version, opt_src) {
+      // HTML import polyfill adds __importElement pointing to the import tag.
+      const script = document.currentScript &&
+          (document.currentScript.__importElement || document.currentScript);
+      let src = opt_src || (script && script.src);
+      if (!src || src.startsWith('data:')) {
+        src = script && script.baseURI;
+      }
+
+      if (opt_version && opt_version !== API_VERSION) {
+        this._failToLoad(`Plugin ${src} install error: only version ` +
+            API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
+            ' was given.', src);
+        return;
+      }
+
+      const pluginObject = this.getPlugin(src);
+      let plugin = pluginObject && pluginObject.plugin;
+      if (!plugin) {
+        plugin = new Plugin(src);
+      }
+      try {
+        callback(plugin);
+        this._pluginInstalled(src, plugin);
+      } catch (e) {
+        this._failToLoad(`${e.name}: ${e.message}`, src);
+      }
+    }
+
+    get arePluginsLoaded() {
+      // As the size of plugins is relatively small,
+      // so the performance of this check should be reasonable
+      if (!this._pluginListLoaded) return false;
+      for (const plugin of this._plugins.values()) {
+        if (plugin.state === PluginState.PENDING) return false;
+      }
+      return true;
+    }
+
+    _checkIfCompleted() {
+      if (this.arePluginsLoaded && this._loadingResolver) {
+        this._loadingResolver();
+        this._loadingResolver = null;
+        this._loadingPromise = null;
+      }
+    }
+
+    _timeout() {
+      const pendingPlugins = [];
+      for (const plugin of this._plugins.values()) {
+        if (plugin.state === PluginState.PENDING) {
+          this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+          this._checkIfCompleted();
+          pendingPlugins.push(plugin.url);
+        }
+      }
+      return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
+    }
+
+    _failToLoad(message, pluginUrl) {
+      // Show an alert with the error
+      document.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: `Plugin install error: ${message} from ${pluginUrl}`,
+        },
+      }));
+      this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+      this._checkIfCompleted();
+    }
+
+    _updatePluginState(pluginUrl, state) {
+      const key = this._getPluginKeyFromUrl(pluginUrl);
+      if (this._plugins.has(key)) {
+        this._plugins.get(key).state = state;
+      } else {
+        // Plugin is not recorded for some reason.
+        console.warn(`Plugin loaded separately: ${pluginUrl}`);
+        this._plugins.set(key, {
+          name: key,
+          url: pluginUrl,
+          state,
+          plugin: null,
+        });
+      }
+      return this._plugins.get(key);
+    }
+
+    _pluginInstalled(url, plugin) {
+      const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+      pluginObj.plugin = plugin;
+      this._getReporting().pluginLoaded(plugin.getPluginName() || url);
+      console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
+      this._checkIfCompleted();
+    }
+
+    installPreloadedPlugins() {
+      if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; }
+      const Gerrit = window.Gerrit;
+      for (const name in Gerrit._preloadedPlugins) {
+        if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
+        const callback = Gerrit._preloadedPlugins[name];
+        this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+      }
+    }
+
+    isPluginPreloaded(pathOrUrl) {
+      const url = this._urlFor(pathOrUrl);
+      const name = getPluginNameFromUrl(url);
+      if (name && window.Gerrit._preloadedPlugins) {
+        return window.Gerrit._preloadedPlugins.hasOwnProperty(name);
+      } else {
+        return false;
+      }
+    }
+
+    /**
+     * Checks if given plugin path/url is enabled or not.
+     *
+     * @param {string} pathOrUrl
+     */
+    isPluginEnabled(pathOrUrl) {
+      const url = this._urlFor(pathOrUrl);
+      if (this.isPluginPreloaded(url)) return true;
+      const key = this._getPluginKeyFromUrl(url);
+      return this._plugins.has(key);
+    }
+
+    /**
+     * Returns the plugin object with a given url.
+     *
+     * @param {string} pathOrUrl
+     */
+    getPlugin(pathOrUrl) {
+      const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl));
+      return this._plugins.get(key);
+    }
+
+    /**
+     * Checks if given plugin path/url is loaded or not.
+     *
+     * @param {string} pathOrUrl
+     */
+    isPluginLoaded(pathOrUrl) {
+      const url = this._urlFor(pathOrUrl);
+      const key = this._getPluginKeyFromUrl(url);
+      return this._plugins.has(key) ?
+        this._plugins.get(key).state === PluginState.LOADED :
+        false;
+    }
+
+    _importHtmlPlugin(pluginUrl, opts = {}) {
+      // onload (second param) needs to be a function. When null or undefined
+      // were passed, plugins were not loaded correctly.
+      (Polymer.importHref || Polymer.Base.importHref)(
+          this._urlFor(pluginUrl), () => {},
+          () => this._failToLoad(`${pluginUrl} import error`, pluginUrl),
+          !opts.sync);
+    }
+
+    _loadJsPlugin(pluginUrl) {
+      this._createScriptTag(this._urlFor(pluginUrl));
+    }
+
+    _createScriptTag(url) {
+      const el = document.createElement('script');
+      el.defer = true;
+      el.src = url;
+      el.onerror = () => this._failToLoad(`${url} load error`, url);
+      return document.body.appendChild(el);
+    }
+
+    _urlFor(pathOrUrl) {
+      if (!pathOrUrl) {
+        return pathOrUrl;
+      }
+      if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
+          pathOrUrl.startsWith('http')) {
+        // Plugins are loaded from another domain or preloaded.
+        return pathOrUrl;
+      }
+      if (!pathOrUrl.startsWith('/')) {
+        pathOrUrl = '/' + pathOrUrl;
+      }
+      return window.location.origin + getBaseUrl() + pathOrUrl;
+    }
+
+    awaitPluginsLoaded() {
+      // Resolve if completed.
+      this._checkIfCompleted();
+
+      if (this.arePluginsLoaded) {
+        return Promise.resolve();
+      }
+      if (!this._loadingPromise) {
+        let timerId;
+        this._loadingPromise =
+          Promise.race([
+            new Promise(resolve => this._loadingResolver = resolve),
+            new Promise((_, reject) => timerId = setTimeout(
+                () => {
+                  reject(this._timeout());
+                }, PLUGIN_LOADING_TIMEOUT_MS)),
+          ]).then(() => {
+            if (timerId) clearTimeout(timerId);
+          });
+      }
+      return this._loadingPromise;
+    }
+  }
+
+  /**
+   * @typedef {{
+   *            name:string,
+   *            url:string,
+   *            state:PluginState,
+   *            plugin:Object
+   *          }}
+   */
+  PluginLoader.PluginObject;
+
+  /**
+   * @typedef {{
+   *            sync:boolean,
+   *          }}
+   */
+  PluginLoader.PluginOption;
+
+  window.PluginLoader = PluginLoader;
+})(window);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
new file mode 100644
index 0000000..ee54319
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
@@ -0,0 +1,502 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-host</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-js-api-interface.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-js-api-interface></gr-js-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  const {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
+  suite('gr-plugin-loader tests', () => {
+    let plugin;
+    let sandbox;
+    let url;
+    let sendStub;
+
+    setup(() => {
+      this.clock = sinon.useFakeTimers();
+      sandbox = sinon.sandbox.create();
+      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+      stub('gr-rest-api-interface', {
+        getAccount() {
+          return Promise.resolve({name: 'Judy Hopps'});
+        },
+        send(...args) {
+          return sendStub(...args);
+        },
+      });
+      sandbox.stub(document.body, 'appendChild');
+      fixture('basic');
+      url = window.location.origin;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+      this.clock.restore();
+      Gerrit._testOnly_resetPlugins();
+    });
+
+    test('reuse plugin for install calls', () => {
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+
+      let otherPlugin;
+      Gerrit.install(p => { otherPlugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.strictEqual(plugin, otherPlugin);
+    });
+
+    test('flushes preinstalls if provided', () => {
+      assert.doesNotThrow(() => {
+        Gerrit._testOnly_flushPreinstalls();
+      });
+      window.Gerrit.flushPreinstalls = sandbox.stub();
+      Gerrit._testOnly_flushPreinstalls();
+      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+      delete window.Gerrit.flushPreinstalls;
+    });
+
+    test('versioning', () => {
+      const callback = sandbox.spy();
+      Gerrit.install(callback, '0.0pre-alpha');
+      assert(callback.notCalled);
+    });
+
+    test('report pluginsLoaded', done => {
+      stub('gr-reporting', {
+        pluginsLoaded() {
+          done();
+        },
+      });
+      Gerrit._loadPlugins([]);
+    });
+
+    test('arePluginsLoaded', done => {
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+
+      Gerrit._loadPlugins(plugins);
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      // Timeout on loading plugins
+      this.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+      flush(() => {
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        done();
+      });
+    });
+
+    test('plugins installed successfully', done => {
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => void 0, undefined, url);
+      });
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+      Gerrit._loadPlugins(plugins);
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        done();
+      });
+    });
+
+    test('isPluginEnabled and isPluginLoaded', done => {
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => void 0, undefined, url);
+      });
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+        'bar/static/test.js',
+      ];
+      Gerrit._loadPlugins(plugins);
+      assert.isTrue(
+          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
+      );
+
+      flush(() => {
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        assert.isTrue(
+            plugins.every(plugin => Gerrit._pluginLoader.isPluginLoaded(plugin))
+        );
+
+        done();
+      });
+    });
+
+    test('plugins installed mixed result, 1 fail 1 succeed', done => {
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+
+      const alertStub = sandbox.stub();
+      document.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        }, undefined, url);
+      });
+
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      Gerrit._loadPlugins(plugins);
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        assert.isTrue(alertStub.calledOnce);
+        done();
+      });
+    });
+
+    test('isPluginEnabled and isPluginLoaded for mixed results', done => {
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+
+      const alertStub = sandbox.stub();
+      document.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        }, undefined, url);
+      });
+
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      Gerrit._loadPlugins(plugins);
+      assert.isTrue(
+          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
+      );
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        assert.isTrue(alertStub.calledOnce);
+        assert.isTrue(Gerrit._pluginLoader.isPluginLoaded(plugins[1]));
+        assert.isFalse(Gerrit._pluginLoader.isPluginLoaded(plugins[0]));
+        done();
+      });
+    });
+
+    test('plugins installed all failed', done => {
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+
+      const alertStub = sandbox.stub();
+      document.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => {
+          throw new Error('failed');
+        }, undefined, url);
+      });
+
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      Gerrit._loadPlugins(plugins);
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        assert.isTrue(alertStub.calledTwice);
+        done();
+      });
+    });
+
+    test('plugins installed failed becasue of wrong version', done => {
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+
+      const alertStub = sandbox.stub();
+      document.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => {
+        }, url === plugins[0] ? '' : 'alpha', url);
+      });
+
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      Gerrit._loadPlugins(plugins);
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        assert.isTrue(alertStub.calledOnce);
+        done();
+      });
+    });
+
+    test('multiple assets for same plugin installed successfully', done => {
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => void 0, undefined, url);
+      });
+      const pluginsLoadedStub = sandbox.stub();
+      stub('gr-reporting', {
+        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+      });
+
+      const plugins = [
+        'http://test.com/plugins/foo/static/test.js',
+        'http://test.com/plugins/foo/static/test2.js',
+        'http://test.com/plugins/bar/static/test.js',
+      ];
+      Gerrit._loadPlugins(plugins);
+
+      flush(() => {
+        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+        assert.isTrue(Gerrit._arePluginsLoaded());
+        done();
+      });
+    });
+
+    suite('plugin path and url', () => {
+      let importHtmlPluginStub;
+      let loadJsPluginStub;
+      setup(() => {
+        importHtmlPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
+          importHtmlPluginStub(url);
+        });
+        loadJsPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+          loadJsPluginStub(url);
+        });
+      });
+
+      test('invalid plugin path', () => {
+        const failToLoadStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_failToLoad', (...args) => {
+          failToLoadStub(...args);
+        });
+
+        Gerrit._loadPlugins([
+          'foo/bar',
+        ]);
+
+        assert.isTrue(failToLoadStub.calledOnce);
+        assert.isTrue(failToLoadStub.calledWithExactly(
+            `Unrecognized plugin url ${url}/foo/bar`,
+            `${url}/foo/bar`
+        ));
+      });
+
+      test('relative path for plugins', () => {
+        Gerrit._loadPlugins([
+          'foo/bar.js',
+          'foo/bar.html',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
+        );
+      });
+
+
+      test('relative path should honor getBaseUrl', () => {
+        const testUrl = '/test';
+        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => {
+          return testUrl;
+        });
+
+        Gerrit._loadPlugins([
+          'foo/bar.js',
+          'foo/bar.html',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(
+                `${url}${testUrl}/foo/bar.html`
+            )
+        );
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+        );
+      });
+
+      test('absolute path for plugins', () => {
+        Gerrit._loadPlugins([
+          'http://e.com/foo/bar.js',
+          'http://e.com/foo/bar.html',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
+        );
+      });
+    });
+
+    test('adds js plugins will call the body', () => {
+      Gerrit._loadPlugins([
+        'http://e.com/foo/bar.js',
+        'http://e.com/bar/foo.js',
+      ]);
+      assert.isTrue(document.body.appendChild.calledTwice);
+    });
+
+    test('can call awaitPluginsLoaded multiple times', done => {
+      const plugins = [
+        'http://e.com/foo/bar.js',
+        'http://e.com/bar/foo.js',
+      ];
+
+      let installed = false;
+      function pluginCallback(url) {
+        if (url === plugins[1]) {
+          installed = true;
+        }
+      }
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        Gerrit.install(() => pluginCallback(url), undefined, url);
+      });
+
+      Gerrit._loadPlugins(plugins);
+
+      Gerrit.awaitPluginsLoaded().then(() => {
+        assert.isTrue(installed);
+
+        Gerrit.awaitPluginsLoaded().then(() => {
+          done();
+        });
+      });
+    });
+
+    suite('preloaded plugins', () => {
+      test('skips preloaded plugins when load plugins', () => {
+        const importHtmlPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
+          importHtmlPluginStub(url);
+        });
+        const loadJsPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+          loadJsPluginStub(url);
+        });
+
+        Gerrit._preloadedPlugins = {
+          foo: () => void 0,
+          bar: () => void 0,
+        };
+
+        Gerrit._loadPlugins([
+          'http://e.com/plugins/foo.js',
+          'plugins/bar.html',
+          'http://e.com/plugins/test/foo.js',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.notCalled);
+        assert.isTrue(loadJsPluginStub.calledOnce);
+      });
+
+      test('isPluginPreloaded', () => {
+        Gerrit._preloadedPlugins = {baz: ()=>{}};
+        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('plugins/foo/bar'));
+        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('http://a.com/42'));
+        assert.isTrue(
+            Gerrit._pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
+        );
+        Gerrit._preloadedPlugins = null;
+      });
+
+      test('preloaded plugins are installed', () => {
+        const installStub = sandbox.stub();
+        Gerrit._preloadedPlugins = {foo: installStub};
+        Gerrit._pluginLoader.installPreloadedPlugins();
+        assert.isTrue(installStub.called);
+        const pluginApi = installStub.lastCall.args[0];
+        assert.strictEqual(pluginApi.getPluginName(), 'foo');
+      });
+
+      test('installing preloaded plugin', () => {
+        let plugin;
+        window.ASSETS_PATH = 'http://blips.com/chitz';
+        Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+        assert.strictEqual(plugin.getPluginName(), 'foo');
+        assert.strictEqual(plugin.url('/some/thing.html'),
+            'http://blips.com/chitz/plugins/foo/some/thing.html');
+        delete window.ASSETS_PATH;
+      });
+    });
+  });
+</script>
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 ce619a0..ecfafb5 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
@@ -46,6 +46,19 @@
     getRestApi().invalidateReposCache();
   };
 
+  GrPluginRestApi.prototype.getAccount = function() {
+    return getRestApi().getAccount();
+  };
+
+  GrPluginRestApi.prototype.getAccountCapabilities = function(capabilities) {
+    return getRestApi().getAccountCapabilities(capabilities);
+  };
+
+  GrPluginRestApi.prototype.getRepos =
+    function(filter, reposPerPage, opt_offset) {
+      return getRestApi().getRepos(filter, reposPerPage, opt_offset);
+    };
+
   /**
    * Fetch and return native browser REST API Response.
    *
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index 7a59337..bcbd961 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-rest-api</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-js-api-interface.html"/>
 
@@ -48,7 +50,6 @@
         a[k] = (...args) => restApiStub[k](...args);
         return a;
       }, {}));
-      Gerrit._setPluginsCount(1);
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       instance = new GrPluginRestApi();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 7376a12..b261a90 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -17,71 +17,18 @@
 (function(window) {
   'use strict';
 
-  /**
-   * Hash of loaded and installed plugins, name to Plugin object.
-   */
-  const _plugins = {};
-
-  /**
-   * Array of plugin URLs to be loaded, name to url.
-   */
-  let _pluginsPending = {};
-
-  let _pluginsInstalled = [];
-
-  let _pluginsPendingCount = -1;
-
   const PRELOADED_PROTOCOL = 'preloaded:';
 
-  const UNKNOWN_PLUGIN = 'unknown';
-
   const PANEL_ENDPOINTS_MAPPING = {
     CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
     CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
   };
 
-  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-  let _restAPI;
-
-  const getRestAPI = () => {
-    if (!_restAPI) {
-      _restAPI = document.createElement('gr-rest-api-interface');
-    }
-    return _restAPI;
-  };
-
-  let _reporting;
-  const getReporting = () => {
-    if (!_reporting) {
-      _reporting = document.createElement('gr-reporting');
-    }
-    return _reporting;
-  };
-
-  // TODO (viktard): deprecate in favor of GrPluginRestApi.
-  function send(method, url, opt_callback, opt_payload) {
-    return getRestAPI().send(method, url, opt_payload).then(response => {
-      if (response.status < 200 || response.status >= 300) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(text);
-          } else {
-            return Promise.reject(response.status);
-          }
-        });
-      } else {
-        return getRestAPI().getResponseObject(response);
-      }
-    }).then(response => {
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
-  }
-
-  const API_VERSION = '0.1';
+  // Import utils methods
+  const {
+    getPluginNameFromUrl,
+    send,
+  } = window._apiUtils;
 
   /**
    * Plugin-provided custom components can affect content in extension
@@ -99,50 +46,6 @@
     STYLE: 'style',
   };
 
-  function flushPreinstalls() {
-    if (window.Gerrit.flushPreinstalls) {
-      window.Gerrit.flushPreinstalls();
-    }
-  }
-
-  function installPreloadedPlugins() {
-    if (!Gerrit._preloadedPlugins) { return; }
-    for (const name in Gerrit._preloadedPlugins) {
-      if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
-      const callback = Gerrit._preloadedPlugins[name];
-      Gerrit.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
-    }
-  }
-
-  function getPluginNameFromUrl(url) {
-    if (!(url instanceof URL)) {
-      try {
-        url = new URL(url);
-      } catch (e) {
-        console.warn(e);
-        return null;
-      }
-    }
-    if (url.protocol === PRELOADED_PROTOCOL) {
-      return url.pathname;
-    }
-    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
-    const pathname = url.pathname.replace(base, '');
-    // Site theme is server from predefined path.
-    if (pathname === '/static/gerrit-theme.html') {
-      return 'gerrit-theme';
-    } else if (!pathname.startsWith('/plugins')) {
-      console.warn('Plugin not being loaded from /plugins base path:',
-          url.href, '— Unable to determine name.');
-      return;
-    }
-    // Pathname should normally look like this:
-    // /plugins/PLUGINNAME/static/SCRIPTNAME.html
-    // Or, for app/samples:
-    // /plugins/PLUGINNAME.html
-    return pathname.split('/')[2].split('.')[0];
-  }
-
   function Plugin(opt_url) {
     this._domHooks = new GrDomHooksManager(this);
 
@@ -315,6 +218,10 @@
     return new GrSettingsApi(this);
   };
 
+  Plugin.prototype.styles = function() {
+    return new GrStylesApi();
+  };
+
   /**
    * To make REST requests for plugin-provided endpoints, use
    *
@@ -361,10 +268,14 @@
       return;
     }
     return this.registerCustomComponent(
-        Gerrit._getPluginScreenName(this.getPluginName(), screenName),
+        this._getScreenName(screenName),
         opt_moduleName);
   };
 
+  Plugin.prototype._getScreenName = function(screenName) {
+    return `${this.getPluginName()}-screen-${screenName}`;
+  };
+
   const deprecatedAPI = {
     _loadedGwt: ()=> {},
 
@@ -415,7 +326,7 @@
             'Please use strings for patterns.');
         return;
       }
-      this.hook(Gerrit._getPluginScreenName(this.getPluginName(), pattern))
+      this.hook(this._getScreenName(pattern))
           .onAttached(el => {
             el.style.display = 'none';
             callback({
@@ -472,243 +383,5 @@
     },
   };
 
-  flushPreinstalls();
-
-  const Gerrit = window.Gerrit || {};
-
-  let _resolveAllPluginsLoaded = null;
-  let _allPluginsPromise = null;
-
-  Gerrit._endpoints = new GrPluginEndpoints();
-
-  // Provide reset plugins function to clear installed plugins between tests.
-  const app = document.querySelector('#app');
-  if (!app) {
-    // No gr-app found (running tests)
-    Gerrit._installPreloadedPlugins = installPreloadedPlugins;
-    Gerrit._flushPreinstalls = flushPreinstalls;
-    Gerrit._resetPlugins = () => {
-      _allPluginsPromise = null;
-      _pluginsInstalled = [];
-      _pluginsPending = {};
-      _pluginsPendingCount = -1;
-      _reporting = null;
-      _resolveAllPluginsLoaded = null;
-      _restAPI = null;
-      Gerrit._endpoints = new GrPluginEndpoints();
-      for (const k of Object.keys(_plugins)) {
-        delete _plugins[k];
-      }
-    };
-  }
-
-  Gerrit.getPluginName = function() {
-    console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
-        'Please use plugin.getPluginName() instead.');
-  };
-
-  Gerrit.css = function(rulesStr) {
-    if (!Gerrit._customStyleSheet) {
-      const styleEl = document.createElement('style');
-      document.head.appendChild(styleEl);
-      Gerrit._customStyleSheet = styleEl.sheet;
-    }
-
-    const name = '__pg_js_api_class_' +
-        Gerrit._customStyleSheet.cssRules.length;
-    Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
-    return name;
-  };
-
-  Gerrit.install = function(callback, opt_version, opt_src) {
-    // HTML import polyfill adds __importElement pointing to the import tag.
-    const script = document.currentScript &&
-        (document.currentScript.__importElement || document.currentScript);
-    const src = opt_src || (script && (script.src || script.baseURI));
-    const name = getPluginNameFromUrl(src);
-
-    if (opt_version && opt_version !== API_VERSION) {
-      Gerrit._pluginInstallError(`Plugin ${name} install error: only version ` +
-          API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
-          ' was given.');
-      return;
-    }
-
-    const existingPlugin = _plugins[name];
-    const plugin = existingPlugin || new Plugin(src);
-    try {
-      callback(plugin);
-      if (name) {
-        _plugins[name] = plugin;
-      }
-      if (!existingPlugin) {
-        Gerrit._pluginInstalled(src);
-      }
-    } catch (e) {
-      Gerrit._pluginInstallError(`${e.name}: ${e.message}`);
-    }
-  };
-
-  Gerrit.getLoggedIn = function() {
-    console.warn('Gerrit.getLoggedIn() is deprecated! ' +
-        'Use plugin.restApi().getLoggedIn()');
-    return document.createElement('gr-rest-api-interface').getLoggedIn();
-  };
-
-  Gerrit.get = function(url, callback) {
-    console.warn('.get() is deprecated! Use plugin.restApi().get()');
-    send('GET', url, callback);
-  };
-
-  Gerrit.post = function(url, payload, callback) {
-    console.warn('.post() is deprecated! Use plugin.restApi().post()');
-    send('POST', url, callback, payload);
-  };
-
-  Gerrit.put = function(url, payload, callback) {
-    console.warn('.put() is deprecated! Use plugin.restApi().put()');
-    send('PUT', url, callback, payload);
-  };
-
-  Gerrit.delete = function(url, opt_callback) {
-    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-    return getRestAPI().send('DELETE', url).then(response => {
-      if (response.status !== 204) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(text);
-          } else {
-            return Promise.reject(response.status);
-          }
-        });
-      }
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
-  };
-
-  Gerrit.awaitPluginsLoaded = function() {
-    if (!_allPluginsPromise) {
-      if (Gerrit._arePluginsLoaded()) {
-        _allPluginsPromise = Promise.resolve();
-      } else {
-        let timeoutId;
-        _allPluginsPromise =
-          Promise.race([
-            new Promise(resolve => _resolveAllPluginsLoaded = resolve),
-            new Promise(resolve => timeoutId = setTimeout(
-                Gerrit._pluginLoadingTimeout, PLUGIN_LOADING_TIMEOUT_MS)),
-          ]).then(() => clearTimeout(timeoutId));
-      }
-    }
-    return _allPluginsPromise;
-  };
-
-  Gerrit._pluginLoadingTimeout = function() {
-    console.error(`Failed to load plugins: ${Object.keys(_pluginsPending)}`);
-    Gerrit._setPluginsPending([]);
-  };
-
-  Gerrit._setPluginsPending = function(plugins) {
-    _pluginsPending = plugins.reduce((o, url) => {
-      // TODO(viktard): Remove guard (@see Issue 8962)
-      o[getPluginNameFromUrl(url) || UNKNOWN_PLUGIN] = url;
-      return o;
-    }, {});
-    Gerrit._setPluginsCount(Object.keys(_pluginsPending).length);
-  };
-
-  Gerrit._setPluginsCount = function(count) {
-    _pluginsPendingCount = count;
-    if (Gerrit._arePluginsLoaded()) {
-      getReporting().pluginsLoaded(_pluginsInstalled);
-      if (_resolveAllPluginsLoaded) {
-        _resolveAllPluginsLoaded();
-      }
-    }
-  };
-
-  Gerrit._pluginInstallError = function(message) {
-    document.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        message: `Plugin install error: ${message}`,
-      },
-    }));
-    console.info(`Plugin install error: ${message}`);
-    Gerrit._setPluginsCount(_pluginsPendingCount - 1);
-  };
-
-  Gerrit._pluginInstalled = function(url) {
-    const name = getPluginNameFromUrl(url) || UNKNOWN_PLUGIN;
-    if (!_pluginsPending[name]) {
-      console.warn(`Unexpected plugin ${name} installed from ${url}.`);
-    } else {
-      delete _pluginsPending[name];
-      _pluginsInstalled.push(name);
-      Gerrit._setPluginsCount(_pluginsPendingCount - 1);
-      console.log(`Plugin ${name} installed.`);
-    }
-  };
-
-  Gerrit._arePluginsLoaded = function() {
-    return _pluginsPendingCount === 0;
-  };
-
-  Gerrit._getPluginScreenName = function(pluginName, screenName) {
-    return `${pluginName}-screen-${screenName}`;
-  };
-
-  Gerrit._isPluginPreloaded = function(url) {
-    const name = getPluginNameFromUrl(url);
-    if (name && Gerrit._preloadedPlugins) {
-      return name in Gerrit._preloadedPlugins;
-    } else {
-      return false;
-    }
-  };
-
-  // TODO(taoalpha): List all internal supported event names.
-  // Also convert this to inherited class once we move Gerrit to class.
-  Gerrit._eventEmitter = new EventEmitter();
-  ['addListener',
-    'dispatch',
-    'emit',
-    'off',
-    'on',
-    'once',
-    'removeAllListeners',
-    'removeListener',
-  ].forEach(method => {
-    /**
-     * Enabling EventEmitter interface on Gerrit.
-     *
-     * This will enable to signal across different parts of js code without relying on DOM,
-     * including core to core, plugin to plugin and also core to plugin.
-     *
-     * @example
-     *
-     * // Emit this event from pluginA
-     * Gerrit.install(pluginA => {
-     *   fetch("some-api").then(() => {
-     *     Gerrit.on("your-special-event", {plugin: pluginA});
-     *   });
-     * });
-     *
-     * // Listen on your-special-event from pluignB
-     * Gerrit.install(pluginB => {
-     *   Gerrit.on("your-special-event", ({plugin}) => {
-     *     // do something, plugin is pluginA
-     *   });
-     * });
-     */
-    Gerrit[method] = Gerrit._eventEmitter[method].bind(Gerrit._eventEmitter);
-  });
-
-  window.Gerrit = Gerrit;
-
-  // Preloaded plugins should be installed after Gerrit.install() is set,
-  // since plugin preloader substitutes Gerrit.install() temporarily.
-  installPreloadedPlugins();
+  window.Plugin = Plugin;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
index ca5c49f..63a528e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -15,11 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-account-label/gr-account-label.html">
+<link rel="import" href="../gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-icons/gr-icons.html">
 <link rel="import" href="../gr-label/gr-label.html">
@@ -31,7 +32,7 @@
     <style include="shared-styles">
       .placeholder {
         color: var(--deemphasized-text-color);
-        padding-top: .2em;
+        padding-top: var(--spacing-xs);
       }
       .hidden {
         display: none;
@@ -39,9 +40,10 @@
       .voteChip {
         display: flex;
         justify-content: center;
-        margin-right: .3em;
-        padding: .05em .85em;
+        margin-right: var(--spacing-s);
+        padding: 0;
         @apply --vote-chip-styles;
+        border-width: 0;
       }
       .max {
         background-color: var(--vote-color-approved);
@@ -59,30 +61,31 @@
         display: none;
       }
       td {
-        vertical-align: middle;
+        vertical-align: top;
       }
       tr {
-        min-height: 2.25em;
+        min-height: var(--line-height-normal);
       }
       gr-button {
+        vertical-align: top;
         --gr-button: {
-          height: 2em;
+          height: var(--line-height-normal);
+          width: var(--line-height-normal);
           padding: 0;
-          width: 2em;
         }
       }
       gr-button[disabled] iron-icon {
         color: var(--border-color);
       }
       gr-account-chip {
-        margin-right: .25em;
+        margin-right: var(--spacing-xs);
       }
       iron-icon {
-        height: 1.2em;
-        width: 1.2em;
+        height: calc(var(--line-height-normal) - 2px);
+        width: calc(var(--line-height-normal) - 2px);
       }
       .labelValueContainer:not(:first-of-type) td {
-        padding-top: .3em;
+        padding-top: var(--spacing-s);
       }
     </style>
     <table>
@@ -111,7 +114,7 @@
             <gr-button
                 link
                 aria-label="Remove"
-                on-tap="_onDeleteVote"
+                on-click="_onDeleteVote"
                 tooltip="Remove vote"
                 data-account-id$="[[mappedLabel.account._account_id]]"
                 class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 1050e42..3c27c94 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-label-info',
-    _legacyUndefinedCheck: true,
 
     properties: {
       labelInfo: Object,
@@ -38,7 +37,7 @@
      */
     _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
       const result = [];
-      if (!labelInfo) { return result; }
+      if (!labelInfo || !account) { return result; }
       if (!labelInfo.values) {
         if (labelInfo.rejected || labelInfo.approved) {
           const ok = labelInfo.approved || !labelInfo.rejected;
@@ -149,7 +148,7 @@
      *    order to trigger computation when a label is removed from the change.
      */
     _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
-      if (labelInfo.all) {
+      if (labelInfo && labelInfo.all) {
         for (const label of labelInfo.all) {
           if (label.value && label.value != labelInfo.default_value) {
             return 'hidden';
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
index 8bc358d..35dc772 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-info</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-label-info.html">
 
@@ -98,7 +100,6 @@
         assert.isTrue(button.disabled);
         return deleteResponse.then(() => {
           assert.isFalse(button.disabled);
-          assert.notOk(element.change.labels.test.recommended);
           assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
         });
       });
@@ -227,4 +228,4 @@
       assert.isTrue(isHidden(element.$$('.placeholder')));
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
index fe290b7..55ecc98 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <dom-module id="gr-label">
   <template strip-whitespace>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
index c437885..0de0881 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-label',
-    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.TooltipBehavior,
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
index c001ce7..da0b93f 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -28,7 +28,7 @@
       #container {
         background: var(--chip-background-color);
         border-radius: 1em;
-        padding: .5em;
+        padding: var(--spacing-m);
       }
       #header {
         color: var(--deemphasized-text-color);
@@ -48,7 +48,7 @@
         border-left: 1px solid var(--deemphasized-text-color);
         color: var(--deemphasized-text-color);
         cursor: pointer;
-        padding-left: .4em;
+        padding-left: var(--spacing-s);
       }
       #trigger:hover {
         color: var(--primary-text-color);
@@ -64,7 +64,7 @@
             disabled="[[disabled]]"
             placeholder="[[placeholder]]"
             borderless></gr-autocomplete>
-        <div id="trigger" on-tap="_handleTriggerTap">▼</div>
+        <div id="trigger" on-click="_handleTriggerClick">▼</div>
       </div>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
index a892522..fd0f228 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-labeled-autocomplete',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a value is chosen.
@@ -59,7 +58,7 @@
       },
     },
 
-    _handleTriggerTap(e) {
+    _handleTriggerClick(e) {
       // Stop propagation here so we don't confuse gr-autocomplete, which
       // listens for taps on body to try to determine when it's blurred.
       e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
index 6bcaa18..b257746 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-labeled-autocomplete</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-labeled-autocomplete.html">
 
@@ -47,7 +49,7 @@
       const e = {stopPropagation: () => undefined};
       sandbox.stub(e, 'stopPropagation');
       sandbox.stub(element.$.autocomplete, 'focus');
-      element._handleTriggerTap(e);
+      element._handleTriggerClick(e);
       assert.isTrue(e.stopPropagation.calledOnce);
       assert.isTrue(element.$.autocomplete.focus.calledOnce);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
index 4137485..fb55c67 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-lib-loader">
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index ba86b66..5ea5dca 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-lib-loader',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _hljsState: {
@@ -68,14 +67,16 @@
      * custom-style DOM element.
      *
      * @return {!Promise<Element>}
+     * @suppress {checkTypes}
      */
     getDarkTheme() {
       return new Promise((resolve, reject) => {
-        this.importHref(this._getLibRoot() + DARK_THEME_PATH, () => {
-          const module = document.createElement('style', 'custom-style');
-          module.setAttribute('include', 'dark-theme');
-          resolve(module);
-        });
+        (this.importHref || Polymer.importHref)(
+            this._getLibRoot() + DARK_THEME_PATH, () => {
+              const module = document.createElement('style', 'custom-style');
+              module.setAttribute('include', 'dark-theme');
+              resolve(module);
+            });
       });
     },
 
@@ -139,7 +140,7 @@
           return;
         }
 
-        script.src = src;
+        script.setAttribute('src', src);
         script.onload = resolve;
         script.onerror = reject;
         Polymer.dom(document.head).appendChild(script);
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
index cf9a41c..10d1608 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-lib-loader</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-lib-loader.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
index 91866e5..d00416b 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 
 <dom-module id="gr-limited-text">
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index 44a8791..048e4f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -26,7 +26,6 @@
 
   Polymer({
     is: 'gr-limited-text',
-    _legacyUndefinedCheck: true,
 
     properties: {
       /** The un-truncated text to display. */
@@ -45,6 +44,15 @@
       },
 
       /**
+       * Disable the tooltip.
+       * When set to true, will not show tooltip even text is over limit
+       */
+      disableTooltip: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
        * The maximum number of characters to display in the tooltop.
        */
       tooltipLimit: {
@@ -66,8 +74,13 @@
      * enabled.
      */
     _updateTitle(text, limit, tooltipLimit) {
+      // Polymer 2: check for undefined
+      if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
+        return;
+      }
+
       this.hasTooltip = !!limit && !!text && text.length > limit;
-      if (this.hasTooltip) {
+      if (this.hasTooltip && !this.disableTooltip) {
         this.setAttribute('title', text.substr(0, tooltipLimit));
       } else {
         this.removeAttribute('title');
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
index 16eb960..7946bb6 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-limited-text</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-limited-text.html">
@@ -90,5 +92,14 @@
       assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
       assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
     });
+
+    test('when disable tooltip', () => {
+      sandbox.spy(element, '_updateTitle');
+      element.text = 'abcdefghijklmn';
+      element.disableTooltip = true;
+      element.limit = 10;
+      flushAsynchronousOperations();
+      assert.equal(element.getAttribute('title'), null);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index fab562a..f3e0906 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-icons/gr-icons.html">
@@ -34,25 +35,31 @@
         background: var(--chip-background-color);
         border-radius: .75em;
         display: inline-flex;
-        padding: 0 .5em;
+        padding: 0 var(--spacing-m);
       }
+      gr-button.remove {
+        --gr-remove-button-style: {
+          border: 0;
+          color: var(--deemphasized-text-color);
+          font-weight: normal;
+          height: .6em;
+          line-height: 10px;
+          margin-left: var(--spacing-xs);
+          padding: 0;
+          text-decoration: none;
+        }
+      }
+
       gr-button.remove:hover,
       gr-button.remove:focus {
         --gr-button: {
+          @apply --gr-remove-button-style;
           color: #333;
         }
       }
       gr-button.remove {
         --gr-button: {
-          border: 0;
-          color: var(--deemphasized-text-color);
-          font-size: 1.7rem;
-          font-weight: normal;
-          height: .6em;
-          line-height: .6;
-          margin-left: .15em;
-          padding: 0;
-          text-decoration: none;
+          @apply --gr-remove-button-style;
         }
       }
       .transparentBackground,
@@ -83,7 +90,7 @@
           hidden$="[[!removable]]"
           hidden
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-tap="_handleRemoveTap">
+          on-click="_handleRemoveTap">
         <iron-icon icon="gr-icons:close"></iron-icon>
       </gr-button>
     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index 8388a07..33a9c25 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-linked-chip',
-    _legacyUndefinedCheck: true,
 
     properties: {
       href: String,
@@ -42,6 +41,10 @@
       limit: Number,
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index eb57428..22a2eaf 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-chip</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-linked-chip.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
index af70e37..61facc0 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -15,12 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 
-<script src="../../../bower_components/ba-linkify/ba-linkify.js"></script>
+<script src="/bower_components/ba-linkify/ba-linkify.js"></script>
 <script src="link-text-parser.js"></script>
 <dom-module id="gr-linked-text">
   <template>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 157ad5e..229fa19 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-linked-text',
-    _legacyUndefinedCheck: true,
 
     properties: {
       removeZeroWidthSpace: Boolean,
@@ -62,6 +61,7 @@
      *     commentLink patterns
      */
     _contentOrConfigChanged(content, config) {
+      if (!Gerrit.Nav || !Gerrit.Nav.mapCommentlinks) return;
       config = Gerrit.Nav.mapCommentlinks(config);
       const output = Polymer.dom(this.$.output);
       output.textContent = '';
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 9fc92b1..0deff05 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-text</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -276,16 +278,58 @@
       let links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
       element.content = 'xx http://google.com yy';
       links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'http://google.com');
+      assert.equal(links[0].innerHTML, 'http://google.com');
 
       element.content = 'xx https://google.com yy';
       links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
+
+      element.content = 'xx ssh://google.com yy';
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 0);
+
+      element.content = 'xx ftp://google.com yy';
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 0);
+    });
+
+    test('links without leading whitespace are linkified', () => {
+      element.content = 'xx abcmailto:test@google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
+      let links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+      element.content = 'xx defhttp://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'http://google.com');
+      assert.equal(links[0].innerHTML, 'http://google.com');
+
+      element.content = 'xx qwehttps://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
+
+      // Non-latin character
+      element.content = 'xx абвhttps://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
 
       element.content = 'xx ssh://google.com yy';
       links = element.$.output.querySelectorAll('a');
@@ -328,26 +372,4 @@
       assert.isTrue(contentConfigStub.called);
     });
   });
-
-  suite('gr-linked-text with null config', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_contentOrConfigChanged not called without config', () => {
-      const contentStub = sandbox.stub(element, '_contentChanged');
-      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-      element.content = 'some text';
-      assert.isTrue(contentStub.called);
-      assert.isFalse(contentConfigStub.called);
-    });
-  });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 027c632..fa38a66 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -17,23 +17,12 @@
 (function() {
   'use strict';
 
-  const Defs = {};
-
-  /**
-   * @typedef {{
-   *    html: Node,
-   *    position: number,
-   *    length: number,
-   * }}
-   */
-  Defs.CommentLinkItem;
-
   /**
    * Pattern describing URLs with supported protocols.
    *
    * @type {RegExp}
    */
-  const URL_PROTOCOL_PATTERN = /^(https?:\/\/|mailto:)/;
+  const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
 
   /**
    * Construct a parser for linkifying text. Will linkify plain URLs that appear
@@ -73,7 +62,7 @@
    *
    * @param {string} text The chuml of source text over which the outputArray
    *     items range.
-   * @param {!Array<Defs.CommentLinkItem>} outputArray The list of items to add
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The list of items to add
    *     resulting from commentlink matches.
    */
   GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
@@ -109,7 +98,7 @@
    * Sort the given array of CommentLinkItems such that the positions are in
    * reverse order.
    *
-   * @param {!Array<Defs.CommentLinkItem>} outputArray
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray
    */
   GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
     outputArray.sort((a, b) => b.position - a.position);
@@ -132,7 +121,7 @@
    *     starts.
    * @param {number} length The number of characters in the source text
    *     represented by the item.
-   * @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
    *     new item is to be appended.
    */
   GrLinkTextParser.prototype.addItem =
@@ -174,7 +163,7 @@
    *     starts.
    * @param {number} length The number of characters in the source text
    *     represented by the link.
-   * @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
    *     new item is to be appended.
    */
   GrLinkTextParser.prototype.addLink =
@@ -196,7 +185,7 @@
    *     starts.
    * @param {number} length The number of characters in the source text
    *     represented by the item.
-   * @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
    *     new item is to be appended.
    */
   GrLinkTextParser.prototype.addHTML =
@@ -214,7 +203,7 @@
    *
    * @param {number} position
    * @param {number} length
-   * @param {!Array<Defs.CommentLinkItem>} outputArray
+   * @param {!Array<Gerrit.CommentLinkItem>} outputArray
    */
   GrLinkTextParser.prototype.hasOverlap =
       function(position, length, outputArray) {
@@ -238,9 +227,11 @@
    * @param {string} text
    */
   GrLinkTextParser.prototype.parse = function(text) {
-    linkify(text, {
-      callback: this.parseChunk.bind(this),
-    });
+    if (text) {
+      linkify(text, {
+        callback: this.parseChunk.bind(this),
+      });
+    }
   };
 
   /**
@@ -265,13 +256,29 @@
     // the source text does not include a protocol, the protocol will be added
     // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
-    if (href && URL_PROTOCOL_PATTERN.test(href)) {
-      this.addText(text, href);
-    } else {
-      // For the sections of text that lie between the links found by
-      // ba-linkify, we search for the project-config-specified link patterns.
-      this.parseLinks(text, this.linkConfig);
+    if (href) {
+      const result = URL_PROTOCOL_PATTERN.exec(href);
+      if (result) {
+        const prefixText = result[1];
+        if (prefixText.length > 0) {
+          // Fix for simple cases from
+          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+          // When leading whitespace is missed before link,
+          // linkify add this text before link as a schema name to href.
+          // We suppose, that prefixText just a single word
+          // before link and add this word as is, without processing
+          // any patterns in it.
+          this.parseLinks(prefixText, []);
+          text = text.substring(prefixText.length);
+          href = href.substring(prefixText.length);
+        }
+        this.addText(text, href);
+        return;
+      }
     }
+    // For the sections of text that lie between the links found by
+    // ba-linkify, we search for the project-config-specified link patterns.
+    this.parseLinks(text, this.linkConfig);
   };
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
index be02d40..3d41a7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -14,9 +14,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -25,7 +28,6 @@
   <template>
     <style include="shared-styles">
       #filter {
-        font-size: var(--font-size-normal);
         max-width: 25em;
       }
       #filter:focus {
@@ -36,7 +38,7 @@
         display: flex;
         height: 3rem;
         justify-content: space-between;
-        margin: 0 1em;
+        margin: 0 var(--spacing-l);
       }
       #createNewContainer:not(.show) {
         display: none;
@@ -68,20 +70,26 @@
     <div id="topContainer">
       <div class="filterContainer">
         <label>Filter:</label>
-        <input is="iron-input"
+        <iron-input
             type="text"
-            id="filter"
             bind-value="{{filter}}">
+          <input
+              is="iron-input"
+              type="text"
+              id="filter"
+              bind-value="{{filter}}">
+        </iron-input>
       </div>
       <div id="createNewContainer"
           class$="[[_computeCreateClass(createNew)]]">
-        <gr-button primary link id="createNew" on-tap="_createNewItem">
+        <gr-button primary link id="createNew" on-click="_createNewItem">
           Create New
         </gr-button>
       </div>
     </div>
     <slot></slot>
     <nav>
+      Page [[_computePage(offset, itemsPerPage)]]
       <a id="prevArrow"
           href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
           hidden$="[[_hidePrevArrow(loading, offset)]]" hidden>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 53d05e1..6840e97 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -21,7 +21,6 @@
 
   Polymer({
     is: 'gr-list-view',
-    _legacyUndefinedCheck: true,
 
     properties: {
       createNew: Boolean,
@@ -38,6 +37,7 @@
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.FireBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
@@ -90,11 +90,18 @@
     },
 
     _hideNextArrow(loading, items) {
-      let lastPage = false;
-      if (items.length < this.itemsPerPage + 1) {
-        lastPage = true;
+      if (loading || !items || !items.length) {
+        return true;
       }
-      return loading || lastPage || !items || !items.length;
+      const lastPage = items.length < this.itemsPerPage + 1;
+      return lastPage;
+    },
+
+    // TODO: fix offset (including itemsPerPage)
+    // to either support a decimal or make it go to the nearest
+    // whole number (e.g 3).
+    _computePage(offset, itemsPerPage) {
+      return offset / itemsPerPage + 1;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
index 09e68dd..ea1dcbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-list-view</title>
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/bower_components/page/page.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-list-view.html">
@@ -153,5 +155,10 @@
       element.path = TAGS_PATH;
       assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
     });
+
+    test('_computePage', () => {
+      assert.equal(element._computePage(0, 25), 1);
+      assert.equal(element._computePage(50, 25), 3);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index e94b655..2b4b982 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-overlay">
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index c167b3b..8623458 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -23,7 +23,6 @@
 
   Polymer({
     is: 'gr-overlay',
-    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a fullscreen overlay is closed
@@ -45,6 +44,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Polymer.IronOverlayBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
index ee05b69..08b7497 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-overlay</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
index 3885497..f1c3a6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 <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="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-page-nav">
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
index 181c7bc..2e05607 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-page-nav',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _headerHeight: Number,
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
index 428bab3..b384b47 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-page-nav</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.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"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
index d794dd6..ce596f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
@@ -14,8 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
@@ -33,7 +33,7 @@
         display: inline-block;
       }
       iron-icon {
-        margin-bottom: 1.2em;
+        margin-bottom: var(--spacing-l);
       }
     </style>
     <div>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
index 2fccc8d..e2298c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -22,7 +22,6 @@
 
   Polymer({
     is: 'gr-repo-branch-picker',
-    _legacyUndefinedCheck: true,
 
     properties: {
       repo: {
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
index 989e838..1ed9151 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-branch-picker</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-repo-branch-picker.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index f203594..dc07d0f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-auth</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="../../../behaviors/base-url-behavior/base-url-behavior.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
index c5a0dfe..d3500d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-etag-decorator">
   <script src="gr-etag-decorator.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
index 09ae1da..76c8c2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-etag-decorator</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-etag-decorator.js"></script>
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 562980c..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
@@ -15,19 +15,21 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="gr-etag-decorator.html">
 
 <!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
-<script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
-<script src="../../../bower_components/fetch/fetch.js"></script>
+<script src="/bower_components/es6-promise/dist/es6-promise.min.js"></script>
+<script src="/bower_components/fetch/fetch.js"></script>
 
 <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 546d004..8c34e33 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
@@ -17,113 +17,15 @@
 (function() {
   'use strict';
 
-  const Defs = {};
-
-  /**
-   * @typedef {{
-   *    basePatchNum: (string|number),
-   *    patchNum: (number),
-   * }}
-   */
-  Defs.patchRange;
-
-  /**
-   * @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),
-   *   errFn: (function(?Response, string=)|null|undefined),
-   *   params: (Object|null|undefined),
-   *   fetchOptions: (Object|null|undefined),
-   *   anonymizedEndpoint: (string|undefined),
-   *   reportEndpointAsIs: (boolean|undefined),
-   * }}
-   */
-  Defs.ChangeFetchRequest;
-
-  /**
-   * Object to describe a request for passing into _send.
-   * - method is the HTTP method to use in the request.
-   * - url is the URL for the request
-   * - body is a request payload.
-   *     TODO (beckysiegel) remove need for number at least.
-   * - 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.
-   * - contentType is the content type of the body.
-   * - headers is a key-value hash to describe HTTP headers for the request.
-   * - parseResponse states whether the result should be parsed as a JSON
-   *     object using getResponseObject.
-   *
-   * @typedef {{
-   *   method: string,
-   *   url: string,
-   *   body: (string|number|Object|null|undefined),
-   *   errFn: (function(?Response, string=)|null|undefined),
-   *   contentType: (string|null|undefined),
-   *   headers: (Object|undefined),
-   *   parseResponse: (boolean|undefined),
-   *   anonymizedUrl: (string|undefined),
-   *   reportUrlAsIs: (boolean|undefined),
-   * }}
-   */
-  Defs.SendRequest;
-
-  /**
-   * @typedef {{
-   *   changeNum: (string|number),
-   *   method: string,
-   *   patchNum: (string|number|undefined),
-   *   endpoint: string,
-   *   body: (string|number|Object|null|undefined),
-   *   errFn: (function(?Response, string=)|null|undefined),
-   *   contentType: (string|null|undefined),
-   *   headers: (Object|undefined),
-   *   parseResponse: (boolean|undefined),
-   *   anonymizedEndpoint: (string|undefined),
-   *   reportEndpointAsIs: (boolean|undefined),
-   * }}
-   */
-  Defs.ChangeSendRequest;
-
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
   const JSON_PREFIX = ')]}\'';
   const MAX_PROJECT_RESULTS = 25;
-  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
+  // This value is somewhat arbitrary and not based on research or calculations.
+  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
   const PARENT_PATCH_NUM = 'PARENT';
-  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
   const Requests = {
     SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -137,57 +39,11 @@
   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();
-    }
-
-    // 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,
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.PathListBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
@@ -228,7 +84,7 @@
       },
       _sharedFetchPromises: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: new FetchPromisesCache(), // Shared across instances.
       },
       _pendingRequests: {
         type: Object,
@@ -253,131 +109,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 elapsed = (Date.now() - startTime);
-      console.log([
-        'HTTP',
-        status,
-        method,
-        elapsed + 'ms',
-        req.anonymizedUrl || req.url,
-      ].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();
-          return;
-        }
-        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) {
-      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);
     },
 
     /**
@@ -385,32 +158,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));
+      return this._restApiHelper.getResponseObject(response);
     },
 
     getConfig(noCache) {
@@ -421,7 +169,7 @@
         });
       }
 
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/config/server/info',
         reportUrlAsIs: true,
       });
@@ -471,7 +219,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,
@@ -485,7 +233,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: '',
@@ -503,7 +251,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,
@@ -519,7 +267,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,
@@ -529,7 +277,7 @@
     },
 
     getGroupConfig(group, opt_errFn) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/detail',
@@ -547,7 +295,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: '',
@@ -567,7 +315,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: '',
@@ -588,7 +336,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,
@@ -609,7 +357,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,
@@ -625,8 +373,8 @@
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
       const req = {
-        url: `/groups/?owned&q=${encodeName}`,
-        anonymizedUrl: '/groups/owned&q=*',
+        url: `/groups/?owned&g=${encodeName}`,
+        anonymizedUrl: '/groups/owned&g=*',
       };
       return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
@@ -634,7 +382,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',
@@ -642,7 +390,7 @@
     },
 
     getIncludedGroup(groupName) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeURIComponent(groupName)}/groups/`,
         anonymizedUrl: '/groups/*/groups',
       });
@@ -650,7 +398,7 @@
 
     saveGroupName(groupId, name) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
@@ -660,7 +408,7 @@
 
     saveGroupOwner(groupId, ownerId) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
@@ -670,7 +418,7 @@
 
     saveGroupDescription(groupId, description) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
@@ -680,7 +428,7 @@
 
     saveGroupOptions(groupId, options) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
@@ -699,7 +447,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,
@@ -716,7 +464,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);
         }
@@ -726,7 +474,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/*',
@@ -736,7 +484,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/*',
@@ -823,7 +571,7 @@
         prefs.download_scheme = prefs.download_scheme.toLowerCase();
       }
 
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/preferences',
         body: prefs,
@@ -839,7 +587,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,
@@ -855,7 +603,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,
@@ -889,14 +637,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,
@@ -910,7 +658,7 @@
      * @return {!Promise<!Object>}
      */
     getAccountDetails(userId) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
         anonymizedUrl: '/accounts/*/detail',
       });
@@ -928,7 +676,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,
@@ -941,7 +689,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,
@@ -961,7 +709,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');
@@ -1005,7 +753,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
     },
 
@@ -1022,7 +770,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
     },
 
@@ -1039,33 +787,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,
@@ -1092,6 +840,8 @@
     getLoggedIn() {
       return this.getAccount().then(account => {
         return account != null;
+      }).catch(() => {
+        return false;
       });
     },
 
@@ -1108,29 +858,7 @@
     },
 
     checkCredentials() {
-      if (this._credentialCheck.checking) {
-        return;
-      }
-      this._credentialCheck.checking = true;
-      const req = {url: '/accounts/self/detail', reportUrlAsIs: true};
-      // 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.delete('/accounts/self/detail');
-        }
-        return res;
-      }).catch(err => {
-        this._credentialCheck.checking = false;
-      });
+      return this._restApiHelper.checkCredentials();
     },
 
     getDefaultPreferences() {
@@ -1146,6 +874,8 @@
           const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
           return this._fetchSharedCacheURL(req).then(res => {
             if (this._isNarrowScreen()) {
+              // Note that this can be problematic, because the diff will stay
+              // unified even after increasing the window width.
               res.default_diff_view = DiffViewMode.UNIFIED;
             } else {
               res.default_diff_view = res.diff_view;
@@ -1176,7 +906,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,
@@ -1191,7 +921,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,
@@ -1200,45 +930,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;
     },
@@ -1280,7 +971,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) {
@@ -1336,13 +1027,13 @@
         this.ListChangesOption.ALL_COMMITS,
         this.ListChangesOption.ALL_REVISIONS,
         this.ListChangesOption.CHANGE_ACTIONS,
-        this.ListChangesOption.CURRENT_ACTIONS,
         this.ListChangesOption.DETAILED_LABELS,
         this.ListChangesOption.DOWNLOAD_COMMANDS,
         this.ListChangesOption.MESSAGES,
         this.ListChangesOption.SUBMITTABLE,
         this.ListChangesOption.WEB_LINKS,
         this.ListChangesOption.SKIP_MERGEABLE,
+        this.ListChangesOption.SKIP_DIFFSTAT,
       ];
       return this.getConfig(false).then(config => {
         if (config.receive && config.receive.enable_signed_push) {
@@ -1364,7 +1055,8 @@
       const optionsHex = this.listChangesOptionsToHex(
           this.ListChangesOption.ALL_COMMITS,
           this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.SKIP_MERGEABLE
+          this.ListChangesOption.SKIP_MERGEABLE,
+          this.ListChangesOption.SKIP_DIFFSTAT
       );
       return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
           opt_cancelCondition);
@@ -1378,9 +1070,10 @@
      */
     _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};
-        const req = {
+        let req = {
           url,
           errFn: opt_errFn,
           cancelCondition: opt_cancelCondition,
@@ -1388,9 +1081,10 @@
           fetchOptions: this._etags.getOptions(urlWithParams),
           anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
         };
-        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)));
           }
 
@@ -1404,7 +1098,7 @@
           }
 
           const payloadPromise = response ?
-            this._readResponsePayload(response) :
+            this._restApiHelper.readResponsePayload(response) :
             Promise.resolve(null);
 
           return payloadPromise.then(payload => {
@@ -1433,7 +1127,7 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {Defs.patchRange} patchRange
+     * @param {Gerrit.PatchRange} patchRange
      * @param {number=} opt_parentIndex
      */
     getChangeFiles(changeNum, patchRange, opt_parentIndex) {
@@ -1454,7 +1148,7 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {Defs.patchRange} patchRange
+     * @param {Gerrit.PatchRange} patchRange
      */
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
@@ -1487,7 +1181,7 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {Defs.patchRange} patchRange
+     * @param {Gerrit.PatchRange} patchRange
      * @return {!Promise<!Array<!Object>>}
      */
     getChangeOrEditFiles(changeNum, patchRange) {
@@ -1498,18 +1192,6 @@
       return this.getChangeFiles(changeNum, patchRange);
     },
 
-    /**
-     * The closure compiler doesn't realize this.specialFilePathCompare is
-     * valid.
-     *
-     * @suppress {checkTypes}
-     */
-    getChangeFilePathsAsSpeciallySortedArray(changeNum, patchRange) {
-      return this.getChangeFiles(changeNum, patchRange).then(files => {
-        return Object.keys(files).sort(this.specialFilePathCompare);
-      });
-    },
-
     getChangeRevisionActions(changeNum, patchNum) {
       const req = {
         changeNum,
@@ -1517,15 +1199,7 @@
         patchNum,
         reportEndpointAsIs: true,
       };
-      return this._getChangeURLAndFetch(req).then(revisionActions => {
-        // The rebase button on change screen is always enabled.
-        if (revisionActions.rebase) {
-          revisionActions.rebase.rebaseOnCurrent =
-              !!revisionActions.rebase.enabled;
-          revisionActions.rebase.enabled = true;
-        }
-        return revisionActions;
-      });
+      return this._getChangeURLAndFetch(req);
     },
 
     /**
@@ -1534,9 +1208,24 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
+      return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
+          opt_errFn);
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {string} inputVal
+     * @param {function(?Response, string=)=} opt_errFn
+     */
+    getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) {
+      return this._getChangeSuggestedGroup('CC', changeNum, inputVal,
+          opt_errFn);
+    },
+
+    _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) {
       // More suggestions may obscure content underneath in the reply dialog,
       // see issue 10793.
-      const params = {n: 6};
+      const params = {'n': 6, 'reviewer-state': reviewerState};
       if (inputVal) { params.q = inputVal; }
       return this._getChangeURLAndFetch({
         changeNum,
@@ -1618,11 +1307,11 @@
     },
 
     invalidateGroupsCache() {
-      this._invalidateSharedFetchPromisesPrefix('/groups/?');
+      this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
     },
 
     invalidateReposCache() {
-      this._invalidateSharedFetchPromisesPrefix('/projects/?');
+      this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
     },
 
     /**
@@ -1660,7 +1349,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},
@@ -1684,7 +1373,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?*',
@@ -1708,7 +1397,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',
@@ -1727,7 +1416,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',
@@ -1737,7 +1426,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',
@@ -1747,7 +1436,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,
@@ -1756,7 +1445,7 @@
     },
 
     setRepoAccessRightsForReview(projectName, projectInfo) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
@@ -1773,7 +1462,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,
@@ -1793,7 +1482,7 @@
         type: 'ALL',
       };
       if (opt_n) { params.n = opt_n; }
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/projects/',
         errFn: opt_errFn,
         params,
@@ -1812,7 +1501,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,
@@ -1843,7 +1532,7 @@
                 throw Error('Unsupported HTTP method: ' + method);
             }
 
-            return this._send({method, url, body});
+            return this._restApiHelper.send({method, url, body});
           });
     },
 
@@ -1873,7 +1562,7 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/conflicts:*',
@@ -1895,7 +1584,7 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/change:*',
@@ -1918,7 +1607,7 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/topic:*',
@@ -1964,7 +1653,7 @@
         this.getChangeActionURL(changeNum, patchNum, '/review'),
       ];
       return Promise.all(promises).then(([, url]) => {
-        return this._send({
+        return this._restApiHelper.send({
           method: 'POST',
           url,
           body: review,
@@ -1998,7 +1687,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: {
@@ -2175,7 +1864,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/*',
@@ -2192,60 +1881,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
@@ -2259,7 +1895,7 @@
      */
     send(method, url, opt_body, opt_errFn, opt_contentType,
         opt_headers) {
-      return this._send({
+      return this._restApiHelper.send({
         method,
         url,
         body: opt_body,
@@ -2524,7 +2160,7 @@
     },
 
     getCommitInfo(project, commit) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
         anonymizedUrl: '/projects/*/comments/*',
@@ -2532,7 +2168,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));
@@ -2656,7 +2292,7 @@
     },
 
     deleteAccountHttpPassword() {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
         reportUrlAsIs: true,
@@ -2669,7 +2305,7 @@
      * parameter.
      */
     generateAccountHttpPassword() {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/password.http',
         body: {generate: true},
@@ -2693,7 +2329,7 @@
         contentType: 'text/plain',
         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'));
@@ -2707,7 +2343,7 @@
     },
 
     deleteAccountSSHKey(id) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
         anonymizedUrl: '/accounts/self/sshkeys/*',
@@ -2715,7 +2351,7 @@
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/self/gpgkeys',
         reportUrlAsIs: true,
       });
@@ -2728,7 +2364,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'));
@@ -2742,7 +2378,7 @@
     },
 
     deleteAccountGPGKey(id) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
         anonymizedUrl: '/accounts/self/gpgkeys/*',
@@ -2775,7 +2411,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.';
         }
@@ -2784,7 +2420,7 @@
     },
 
     getCapabilities(opt_errFn) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
         reportUrlAsIs: true,
@@ -2792,7 +2428,7 @@
     },
 
     getTopMenus(opt_errFn) {
-      return this._fetchJSON({
+      return this._fetchSharedCacheURL({
         url: '/config/server/top-menus',
         errFn: opt_errFn,
         reportUrlAsIs: true,
@@ -2890,7 +2526,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:*',
@@ -2941,7 +2577,7 @@
      * Alias for _changeBaseURL.then(send).
      *
      * @todo(beckysiegel) clean up comments
-     * @param {Defs.ChangeSendRequest} req
+     * @param {Gerrit.ChangeSendRequest} req
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndSend(req) {
@@ -2951,7 +2587,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,
@@ -2968,7 +2604,7 @@
     /**
      * Alias for _changeBaseURL.then(_fetchJSON).
      *
-     * @param {Defs.ChangeFetchRequest} req
+     * @param {Gerrit.ChangeFetchRequest} req
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndFetch(req) {
@@ -2977,7 +2613,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,
@@ -3108,7 +2744,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 ef4e401..635e0f5 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-interface</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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>
 
@@ -61,93 +63,8 @@
       sandbox.restore();
     });
 
-    test('JSON prefix is properly removed', done => {
-      element._fetchJSON('/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': [
@@ -290,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({
@@ -404,37 +321,6 @@
           ]);
     });
 
-    suite('rebase action', () => {
-      let resolve_fetchJSON;
-      setup(() => {
-        sandbox.stub(element, '_fetchJSON').returns(
-            new Promise(resolve => {
-              resolve_fetchJSON = resolve;
-            }));
-      });
-
-      test('no rebase on current', done => {
-        element.getChangeRevisionActions('42', '1337').then(
-            response => {
-              assert.isTrue(response.rebase.enabled);
-              assert.isFalse(response.rebase.rebaseOnCurrent);
-              done();
-            });
-        resolve_fetchJSON({rebase: {}});
-      });
-
-      test('rebase on current', done => {
-        element.getChangeRevisionActions('42', '1337').then(
-            response => {
-              assert.isTrue(response.rebase.enabled);
-              assert.isTrue(response.rebase.rebaseOnCurrent);
-              done();
-            });
-        resolve_fetchJSON({rebase: {enabled: true}});
-      });
-    });
-
-
     test('server error', done => {
       const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
       window.fetch.returns(Promise.resolve({ok: false}));
@@ -442,7 +328,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());
@@ -458,12 +344,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('/bar').then(r => {
+      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
@@ -473,6 +359,34 @@
       });
     });
 
+    test('auth failure - test all failed to fetch', done => {
+      window.fetch.returns(
+          Promise.reject(new Error('Failed to fetch')));
+      // Emulate logged in.
+      element._cache.set('/accounts/self/detail', {});
+      const serverErrorStub = sandbox.stub();
+      element.addEventListener('server-error', serverErrorStub);
+      const authErrorStub = sandbox.stub();
+      element.addEventListener('auth-error', authErrorStub);
+      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
+        flush(() => {
+          assert.isTrue(authErrorStub.called);
+          assert.isFalse(serverErrorStub.called);
+          assert.isFalse(element._cache.has('/accounts/self/detail'));
+          done();
+        });
+      });
+    });
+
+    test('getLoggedIn returns false when network/auth failure', done => {
+      window.fetch.returns(
+          Promise.reject(new Error('Failed to fetch')));
+      element.getLoggedIn().then(isLoggedIn => {
+        assert.isFalse(isLoggedIn);
+        done();
+      });
+    });
+
     test('checkCredentials', done => {
       const responses = [
         {
@@ -505,7 +419,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'));
       });
@@ -517,13 +432,22 @@
             // 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);
           });
     });
 
+    test('checkCredentials accepts only json', () => {
+      const authFetchStub = sandbox.stub(element._auth, 'fetch')
+          .returns(Promise.resolve());
+      element.checkCredentials();
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          'application/json');
+    });
+
     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);
@@ -531,38 +455,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');
@@ -571,15 +495,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({});
     });
@@ -591,7 +515,7 @@
       sandbox.stub(element, '_isNarrowScreen', () => {
         return smallScreen;
       });
-      sandbox.stub(element, '_fetchSharedCacheURL', () => {
+      sandbox.stub(element._restApiHelper, 'fetchCacheURL', () => {
         return Promise.resolve(testJSON);
       });
     };
@@ -656,10 +580,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 => {
@@ -685,10 +609,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 => {
@@ -718,34 +642,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'});
       });
     });
 
@@ -834,18 +760,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);
           });
     });
 
@@ -853,17 +781,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});
       });
     });
 
@@ -919,7 +848,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);
@@ -965,64 +894,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}`);
       });
     });
@@ -1051,43 +981,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('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);
@@ -1095,7 +1027,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, {
@@ -1167,24 +1099,35 @@
         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);
         });
       });
 
+      test('_getChangeDetail accepts only json', () => {
+        const authFetchStub = sandbox.stub(element._auth, 'fetch')
+            .returns(Promise.resolve());
+        const errFn = sinon.stub();
+        element._getChangeDetail(123, '516714', errFn);
+        assert.isTrue(authFetchStub.called);
+        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+            'application/json');
+      });
+
       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');
@@ -1202,7 +1145,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');
@@ -1210,11 +1154,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);
@@ -1225,11 +1170,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);
@@ -1274,7 +1220,7 @@
 
     suite('getChanges populates _projectLookup', () => {
       test('multiple queries', () => {
-        sandbox.stub(element, '_fetchJSON')
+        sandbox.stub(element._restApiHelper, 'fetchJSON')
             .returns(Promise.resolve([
               [
                 {_number: 1, project: 'test'},
@@ -1294,7 +1240,7 @@
       });
 
       test('no query', () => {
-        sandbox.stub(element, '_fetchJSON')
+        sandbox.stub(element._restApiHelper, 'fetchJSON')
             .returns(Promise.resolve([
               {_number: 1, project: 'test'},
               {_number: 2, project: 'test'},
@@ -1314,7 +1260,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(() => {
@@ -1325,7 +1271,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 = {
@@ -1347,16 +1293,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);
       });
     });
@@ -1378,7 +1325,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});
@@ -1463,11 +1410,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');
     });
 
@@ -1535,7 +1483,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'};
@@ -1543,7 +1491,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);
@@ -1555,10 +1503,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);
     });
@@ -1567,7 +1516,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..3908a00
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -0,0 +1,431 @@
+/**
+ * @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 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 {Gerrit.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 {Gerrit.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 {Gerrit.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 {Gerrit.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 {Gerrit.FetchJSONRequest} req
+     * @return {Gerrit.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 {Gerrit.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 {Gerrit.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-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
index 8ddb255..d883ef6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -21,7 +21,6 @@
   if (window.GrReviewerUpdatesParser) { return; }
 
   function GrReviewerUpdatesParser(change) {
-    // TODO (viktard): Polyfill Object.assign for IE.
     this.result = Object.assign({}, change);
     this._lastState = {};
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index 202c52a..fdf79af 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-updates-parser</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-reviewer-updates-parser.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
index 05c2cee..015d71e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <dom-module id="mock-diff-response">
   <template></template>
@@ -153,7 +153,7 @@
 
       Polymer({
         is: 'mock-diff-response',
-        _legacyUndefinedCheck: true,
+
         properties: {
           diffResponse: {
             type: Object,
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
index e73d41c..f1ef86a 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
@@ -15,7 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
+
 <dom-module id="gr-select">
   <slot></slot>
   <script src="gr-select.js"></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 85e1a61..05267ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -19,7 +19,7 @@
 
   Polymer({
     is: 'gr-select',
-    _legacyUndefinedCheck: true,
+
     properties: {
       bindValue: {
         type: String,
@@ -28,6 +28,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
     listeners: {
       'change': '_valueChanged',
       'dom-change': '_updateValue',
@@ -55,9 +59,15 @@
       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) { this.bindValue = this.nativeSelect.value; }
+      if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
+        this.bindValue = this.nativeSelect.value;
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index 1748ec06..b3abe5f 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-select</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-select.html">
 
@@ -38,6 +40,15 @@
   </template>
 </test-fixture>
 
+<test-fixture id="noOptions">
+  <template>
+    <gr-select>
+      <select>
+      </select>
+    </gr-select>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-select tests', () => {
     let element;
@@ -46,6 +57,10 @@
       element = fixture('basic');
     });
 
+    test('bindValue must be set to the first option value', () => {
+      assert.equal(element.bindValue, '1');
+    });
+
     test('value of 0 should still trigger value updates', () => {
       element.bindValue = 0;
       assert.equal(element.nativeSelect.value, 0);
@@ -88,4 +103,16 @@
       assert.isTrue(changeStub.called);
     });
   });
+
+  suite('gr-select no options tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('noOptions');
+    });
+
+    test('bindValue must not be changed', () => {
+      assert.isUndefined(element.bindValue);
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
index fe6ed88..15e282f 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
 
@@ -23,25 +23,30 @@
   <template>
     <style include="shared-styles">
       .commandContainer {
-        margin-bottom: .75em;
+        margin-bottom: var(--spacing-m);
       }
       .commandContainer {
         background-color: var(--shell-command-background-color);
-        padding: .5em .5em .5em 2.5em;
+        /* Should be spacing-m larger than the :before width. */
+        padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) calc(3*var(--spacing-m) + 0.5em);
         position: relative;
         width: 100%;
       }
       .commandContainer:before {
-        background: var(--shell-command-decoration-background-color);
-        bottom: 0;
-        box-sizing: border-box;
         content: '$';
-        display: block;
-        left: 0;
-        padding: .8em;
         position: absolute;
+        display: block;
+        box-sizing: border-box;
+        background: var(--shell-command-decoration-background-color);
         top: 0;
-        width: 2em;
+        bottom: 0;
+        left: 0;
+        /* Should be spacing-m smaller than the .commandContainer padding-left. */
+        width: calc(2*var(--spacing-m) + 0.5em);
+        /* Should vertically match the padding of .commandContainer. */
+        padding: var(--spacing-m);
+        /* Should roughly match the height of .commandContainer without padding. */
+        line-height: 26px;
       }
       .commandContainer gr-copy-clipboard {
         --text-container-style: {
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
index 901b8ce..2c546cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-shell-command',
-    _legacyUndefinedCheck: true,
 
     properties: {
       command: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
index a49f76f..3f2f8ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-shell-command</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-shell-command.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
index 6fc2f3f..7215b26 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <dom-module id="gr-storage">
   <script src="gr-storage.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 83cd06c..f6ade6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -30,7 +30,6 @@
 
   Polymer({
     is: 'gr-storage',
-    _legacyUndefinedCheck: true,
 
     properties: {
       _lastCleanup: Number,
@@ -76,14 +75,6 @@
       this._storage.removeItem(this._getEditableContentKey(key));
     },
 
-    getPreferences() {
-      return this._getObject('localPrefs');
-    },
-
-    savePreferences(localPrefs) {
-      this._setObject('localPrefs', localPrefs || null);
-    },
-
     _getDraftKey(location) {
       const range = location.range ?
         `${location.range.start_line}-${location.range.start_character}` +
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 6b89af2..0482584 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-storage.html">
 
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 10c9111..6803eb9 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -14,25 +14,36 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 
 <dom-module id="gr-textarea">
   <template>
     <style include="shared-styles">
       :host {
-        display: block;
+        display: flex;
         position: relative;
       }
       :host(.monospace) {
         font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        font-weight: var(--font-weight-normal);
+      }
+      :host(.code) {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-code);
+        font-weight: var(--font-weight-normal);
       }
       #emojiSuggestions {
         font-family: var(--font-family);
@@ -54,6 +65,9 @@
       iron-autogrow-textarea {
         padding: 2px;
         position: relative;
+
+        /** This is needed for firefox */
+        --iron-autogrow-textarea_-_white-space: pre-wrap;
       }
       #textarea.noBorder {
         border: none;
@@ -92,6 +106,7 @@
         max-rows="[[maxRows]]"
         value="{{text}}"
         on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-textarea.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index 7929fbe..4c4b038 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -20,41 +20,40 @@
   const MAX_ITEMS_DROPDOWN = 10;
 
   const ALL_SUGGESTIONS = [
+    {value: '😊', match: 'smile :)'},
+    {value: '👍', match: 'thumbs up'},
+    {value: '😄', match: 'laugh :D'},
+    {value: '🎉', match: 'party'},
+    {value: '😞', match: 'sad :('},
+    {value: '😂', match: 'tears :\')'},
+    {value: '🙏', match: 'pray'},
+    {value: '😐', match: 'neutral :|'},
+    {value: '😮', match: 'shock :O'},
+    {value: '👎', match: 'thumbs down'},
+    {value: '😎', match: 'cool |;)'},
+    {value: '😕', match: 'confused'},
+    {value: '👌', match: 'ok'},
+    {value: '🔥', match: 'fire'},
+    {value: '👊', match: 'fistbump'},
     {value: '💯', match: '100'},
     {value: '💔', match: 'broken heart'},
     {value: '🍺', match: 'beer'},
     {value: '✔', match: 'check'},
-    {value: '😎', match: 'cool'},
-    {value: '😕', match: 'confused'},
-    {value: '😭', match: 'crying'},
-    {value: '🔥', match: 'fire'},
-    {value: '👊', match: 'fistbump'},
+    {value: '😋', match: 'tongue'},
+    {value: '😭', match: 'crying :\'('},
     {value: '🐨', match: 'koala'},
-    {value: '😄', match: 'laugh'},
     {value: '🤓', match: 'glasses'},
     {value: '😆', match: 'grin'},
-    {value: '😐', match: 'neutral'},
-    {value: '👌', match: 'ok'},
-    {value: '🎉', match: 'party'},
     {value: '💩', match: 'poop'},
-    {value: '🙏', match: 'pray'},
-    {value: '😞', match: 'sad'},
-    {value: '😮', match: 'shock'},
-    {value: '😊', match: 'smile'},
     {value: '😢', match: 'tear'},
-    {value: '😂', match: 'tears'},
-    {value: '😋', match: 'tongue'},
-    {value: '👍', match: 'thumbs up'},
-    {value: '👎', match: 'thumbs down'},
     {value: '😒', match: 'unamused'},
-    {value: '😉', match: 'wink'},
+    {value: '😉', match: 'wink ;)'},
     {value: '🍷', match: 'wine'},
-    {value: '😜', match: 'winking tongue'},
+    {value: '😜', match: 'winking tongue ;)'},
   ];
 
   Polymer({
     is: 'gr-textarea',
-    _legacyUndefinedCheck: true,
 
     /**
      * @event bind-value-changed
@@ -75,15 +74,21 @@
         type: Boolean,
         value: false,
       },
+      /** Text input should be rendered in monspace font.  */
       monospace: {
         type: Boolean,
         value: false,
       },
+      /** Text input should be rendered in code font, which is smaller than the
+          standard monospace font. */
+      code: {
+        type: Boolean,
+        value: false,
+      },
       /** @type(?number) */
       _colonIndex: Number,
       _currentSearchString: {
         type: String,
-        value: '',
         observer: '_determineSuggestions',
       },
       _hideAutocomplete: {
@@ -101,6 +106,7 @@
     },
 
     behaviors: [
+      Gerrit.FireBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -113,10 +119,12 @@
     },
 
     ready() {
-      this._resetEmojiDropdown();
       if (this.monospace) {
         this.classList.add('monospace');
       }
+      if (this.code) {
+        this.classList.add('code');
+      }
       if (this.hideBorder) {
         this.$.textarea.classList.add('noBorder');
       }
@@ -153,6 +161,7 @@
       e.stopPropagation();
       this.$.emojiSuggestions.cursorUp();
       this.$.textarea.textarea.focus();
+      this.disableEnterKeyForSelectingEmoji = false;
     },
 
     _handleDownKey(e) {
@@ -161,24 +170,34 @@
       e.stopPropagation();
       this.$.emojiSuggestions.cursorDown();
       this.$.textarea.textarea.focus();
+      this.disableEnterKeyForSelectingEmoji = false;
     },
 
     _handleEnterByKey(e) {
-      if (this._hideAutocomplete) { return; }
+      if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+        return;
+      }
       e.preventDefault();
       e.stopPropagation();
-      this.text = this._getText(this.$.emojiSuggestions.getCurrentText());
-      this._resetEmojiDropdown();
+      this._setEmoji(this.$.emojiSuggestions.getCurrentText());
     },
 
     _handleEmojiSelect(e) {
-      this.text = this._getText(e.detail.selected.dataset.value);
+      this._setEmoji(e.detail.selected.dataset.value);
+    },
+
+    _setEmoji(text) {
+      const colonIndex = this._colonIndex;
+      this.text = this._getText(text);
+      this.$.textarea.selectionStart = colonIndex + 1;
+      this.$.textarea.selectionEnd = colonIndex + 1;
+      this.$.reporting.reportInteraction('select-emoji');
       this._resetEmojiDropdown();
     },
 
     _getText(value) {
       return this.text.substr(0, this._colonIndex || 0) +
-          value + this.text.substr(this.$.textarea.selectionStart) + ' ';
+          value + this.text.substr(this.$.textarea.selectionStart);
     },
     /**
      * Uses a hidden element with the same width and styling of the textarea and
@@ -218,41 +237,45 @@
       // If cursor is not in textarea (just opened with colon as last char),
       // Don't do anything.
       if (!e.currentTarget.focused) { return; }
-      const newChar = e.detail.value[this.$.textarea.selectionStart - 1];
 
-      // When a colon is detected, set a colon index, but don't do anything else
-      // yet.
-      if (newChar === ':') {
-        this._colonIndex = this.$.textarea.selectionStart - 1;
-      // If the colon index exists, continue to determine what needs to be done
-      // with the dropdown. It may be open or closed at this point.
-      } else if (this._colonIndex !== null) {
-        // The search string is a substring of the textarea's value from (1
-        // position after) the colon index to the cursor position.
-        this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
-            this.$.textarea.selectionStart);
-        // Under the following conditions, close and reset the dropdown:
-        // - The cursor is no longer at the end of the current search string
-        // - The search string is an space or new line
-        // - The colon has been removed
-        // - There are no suggestions that match the search string
-        if (this.$.textarea.selectionStart !==
-            this._currentSearchString.length + this._colonIndex + 1 ||
-            this._currentSearchString === ' ' ||
-            this._currentSearchString === '\n' ||
-            !(e.detail.value[this._colonIndex] === ':') ||
-            !this._suggestions.length) {
-          this._resetEmojiDropdown();
-        // Otherwise open the dropdown and set the position to be just below the
-        // cursor.
-        } else if (this.$.emojiSuggestions.isHidden) {
-          this._updateCaratPosition();
+      const charAtCursor = e.detail && e.detail.value ?
+        e.detail.value[this.$.textarea.selectionStart - 1] : '';
+      if (charAtCursor !== ':' && this._colonIndex == null) { return; }
+
+      // When a colon is detected, set a colon index. We are interested only on
+      // colons after space or in beginning of textarea
+      if (charAtCursor === ':') {
+        if (this.$.textarea.selectionStart < 2 ||
+            e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
+          this._colonIndex = this.$.textarea.selectionStart - 1;
         }
-        this.$.textarea.textarea.focus();
       }
+
+      this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+          this.$.textarea.selectionStart - this._colonIndex - 1);
+      // Under the following conditions, close and reset the dropdown:
+      // - The cursor is no longer at the end of the current search string
+      // - The search string is an space or new line
+      // - The colon has been removed
+      // - There are no suggestions that match the search string
+      if (this.$.textarea.selectionStart !==
+          this._currentSearchString.length + this._colonIndex + 1 ||
+          this._currentSearchString === ' ' ||
+          this._currentSearchString === '\n' ||
+          !(e.detail.value[this._colonIndex] === ':') ||
+          !this._suggestions.length) {
+        this._resetEmojiDropdown();
+      // Otherwise open the dropdown and set the position to be just below the
+      // cursor.
+      } else if (this.$.emojiSuggestions.isHidden) {
+        this._updateCaratPosition();
+      }
+      this.$.textarea.textarea.focus();
     },
+
     _openEmojiDropdown() {
       this.$.emojiSuggestions.open();
+      this.$.reporting.reportInteraction('open-emoji-dropdown');
     },
 
     _formatSuggestions(matchedSuggestions) {
@@ -268,11 +291,14 @@
     _determineSuggestions(emojiText) {
       if (!emojiText.length) {
         this._formatSuggestions(ALL_SUGGESTIONS);
+        this.disableEnterKeyForSelectingEmoji = true;
+      } else {
+        const matches = ALL_SUGGESTIONS.filter(suggestion => {
+          return suggestion.match.includes(emojiText);
+        }).slice(0, MAX_ITEMS_DROPDOWN);
+        this._formatSuggestions(matches);
+        this.disableEnterKeyForSelectingEmoji = false;
       }
-      const matches = ALL_SUGGESTIONS.filter(suggestion => {
-        return suggestion.match.includes(emojiText);
-      }).splice(0, MAX_ITEMS_DROPDOWN);
-      this._formatSuggestions(matches);
     },
 
     _resetEmojiDropdown() {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index f010fb3..b884ecd 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-textarea</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-textarea.html">
 
@@ -31,6 +33,18 @@
   </template>
 </test-fixture>
 
+<test-fixture id="monospace">
+  <template>
+    <gr-textarea monospace="true"></gr-textarea>
+  </template>
+</test-fixture>
+
+<test-fixture id="hideBorder">
+  <template>
+    <gr-textarea hide-border="true"></gr-textarea>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-textarea tests', () => {
     let element;
@@ -39,6 +53,7 @@
     setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
+      sandbox.stub(element.$.reporting, 'reportInteraction');
     });
 
     teardown(() => {
@@ -47,16 +62,10 @@
 
     test('monospace is set properly', () => {
       assert.isFalse(element.classList.contains('monospace'));
-      element.monospace = true;
-      element.ready();
-      assert.isTrue(element.classList.contains('monospace'));
     });
 
     test('hideBorder is set properly', () => {
       assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-      element.hideBorder = true;
-      element.ready();
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
     });
 
     test('emoji selector is not open with the textarea lacks focus', () => {
@@ -82,6 +91,49 @@
           element.$.textarea.selectionStart = 1;
           element.$.textarea.selectionEnd = 1;
           element.text = ':';
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, '');
+        });
+
+    test('emoji selector opens when a colon is typed after space',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 2;
+          element.$.textarea.selectionEnd = 2;
+          element.text = ' :';
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 1);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, '');
+        });
+
+    test('emoji selector doesn\`t open when a colon is typed after character',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 5;
+          element.$.textarea.selectionEnd = 5;
+          element.text = 'test:';
+          flushAsynchronousOperations();
+          assert.isTrue(element.$.emojiSuggestions.isHidden);
+          assert.isTrue(element._hideAutocomplete);
+        });
+
+    test('emoji selector opens when a colon is typed and some substring',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          element.text = ':';
           element.$.textarea.selectionStart = 2;
           element.$.textarea.selectionEnd = 2;
           element.text = ':t';
@@ -92,6 +144,30 @@
           assert.equal(element._currentSearchString, 't');
         });
 
+    test('emoji selector opens when a colon is typed in middle of text',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          // Since selectionStart is on Chrome set always on end of text, we
+          // stub it to 1
+          const text = ': hello';
+          sandbox.stub(element.$, 'textarea', {
+            selectionStart: 1,
+            value: text,
+            textarea: {
+              focus: () => {},
+            },
+          });
+          element.text = text;
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, '');
+        });
     test('emoji selector closes when text changes before the colon', () => {
       const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
       MockInteractions.focus(element.$.textarea);
@@ -131,8 +207,10 @@
       element._determineSuggestions(emojiText);
       assert.isTrue(formatSpy.called);
       assert.isTrue(formatSpy.lastCall.calledWithExactly(
-          [{dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-            {dataValue: '😂', value: '😂', match: 'tears', text: '😂 tears'}]));
+          [{dataValue: '😂', value: '😂', match: 'tears :\')',
+            text: '😂 tears :\')'},
+          {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+          ]));
     });
 
     test('_formatSuggestions', () => {
@@ -153,7 +231,7 @@
       const selectedItem = {dataset: {value: '😂'}};
       const event = {detail: {selected: selectedItem}};
       element._handleEmojiSelect(event);
-      assert.equal(element.text, 'test test 😂 ');
+      assert.equal(element.text, 'test test 😂');
     });
 
     test('_updateCaratPosition', () => {
@@ -228,9 +306,70 @@
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
         assert.isTrue(enterSpy.called);
         flushAsynchronousOperations();
-        // A space is automatically added at the end.
-        assert.equal(element.text, '💯 ');
+        assert.equal(element.text, '💯');
       });
+
+      test('enter key - ignored on just colon without more information', () => {
+        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+            'getCursorTarget');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
+        MockInteractions.focus(element.$.textarea);
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        flushAsynchronousOperations();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
+      });
+    });
+  });
+
+  suite('gr-textarea monospace', () => {
+    // gr-textarea set monospace class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('monospace');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('monospace is set properly', () => {
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  suite('gr-textarea hideBorder', () => {
+    // gr-textarea set noBorder class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('hideBorder');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 65f1fda..b4fefe1 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 
 <dom-module id="gr-tooltip-content">
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index b46cafb..c5de8f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-tooltip-content',
-    _legacyUndefinedCheck: true,
 
     properties: {
       title: {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index 438d436..f9350c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-tooltip-content.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 9947d61..75d9c4b 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-tooltip">
@@ -34,7 +34,7 @@
         max-width: var(--tooltip-max-width);
       }
       :host .tooltip {
-        padding: .5em .85em;
+        padding: var(--spacing-m) var(--spacing-l);
       }
       :host .arrowPositionBelow,
       :host([position-below]) .arrowPositionAbove  {
@@ -54,11 +54,11 @@
       }
       .arrowPositionAbove {
         border-top: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color);
-        bottom: -var(--gr-tooltip-arrow-size);
+        bottom: calc(-1 * var(--gr-tooltip-arrow-size));
       }
       .arrowPositionBelow {
         border-bottom: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color);
-        top: -var(--gr-tooltip-arrow-size);
+        top: calc(-1 * var(--gr-tooltip-arrow-size));
       }
     </style>
     <div class="tooltip">
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index 3e16beb..fb87b558 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -19,7 +19,6 @@
 
   Polymer({
     is: 'gr-tooltip',
-    _legacyUndefinedCheck: true,
 
     properties: {
       text: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index 3a47288..f59f6e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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-tooltip.html">
 
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
index 0846728..48e488a 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
@@ -36,6 +36,9 @@
      * @return {Number}
      */
     RevisionInfo.prototype.getMaxParents = function() {
+      if (!this._change || !this._change.revisions) {
+        return 0;
+      }
       return Object.values(this._change.revisions)
           .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
     };
@@ -48,6 +51,9 @@
      */
     RevisionInfo.prototype.getParentCountMap = function() {
       const result = {};
+      if (!this._change || !this._change.revisions) {
+        return {};
+      }
       Object.values(this._change.revisions)
           .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
       return result;
@@ -75,9 +81,7 @@
       return rev.commit.parents[parentIndex].commit;
     };
 
-    if (!window.Gerrit) {
-      window.Gerrit = {};
-    }
+    window.Gerrit = window.Gerrit || {};
     window.Gerrit.RevisionInfo = RevisionInfo;
   })();
 </script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
index 433872d..7e5810b 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>revision-info</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.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="revision-info.html">
 
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
index bd29b90..ecd9007 100644
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ b/polygerrit-ui/app/elements/test/plugin.html
@@ -1,18 +1,44 @@
 <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>
 
 <dom-module id="myplugin-app-theme">
-  <style>
-    html {
-      --primary-text-color: #F00BAA;
-      --header-background-color: #F01BAA;
-      --header-title-content: "MyGerrit";
-      --footer-background-color: #F02BAA;
-    }
-  </style>
+  <template>
+    <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;
+      }
+    </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/embed/embed.html b/polygerrit-ui/app/embed/embed.html
index 1b2f20f..64e0137 100644
--- a/polygerrit-ui/app/embed/embed.html
+++ b/polygerrit-ui/app/embed/embed.html
@@ -15,12 +15,9 @@
 limitations under the License.
 -->
 <script>
-  // Needed for JSCompiler to understand it's global.
-  // eslint-disable-next-line no-unused-vars, prefer-const
-  let Gerrit = window.Gerrit || {};
-  window.Gerrit = Gerrit;
+  window.Gerrit = window.Gerrit || {};
 </script>
-<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../elements/change/gr-change-view/gr-change-view.html">
 <link rel="import" href="../elements/core/gr-search-bar/gr-search-bar.html">
 <link rel="import" href="../elements/diff/gr-diff-view/gr-diff-view.html">
diff --git a/polygerrit-ui/app/embed/embed_test.html b/polygerrit-ui/app/embed/embed_test.html
index 7ca75c9..1e3f5d7 100644
--- a/polygerrit-ui/app/embed/embed_test.html
+++ b/polygerrit-ui/app/embed/embed_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>embed_test</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.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../polygerrit_ui/elements/embed.html"/>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="embed.html"/>
 
 <script>void(0);</script>
 
diff --git a/polygerrit-ui/app/embed/gr-diff.html b/polygerrit-ui/app/embed/gr-diff.html
index 6aa9370..f5f74bd 100644
--- a/polygerrit-ui/app/embed/gr-diff.html
+++ b/polygerrit-ui/app/embed/gr-diff.html
@@ -15,11 +15,7 @@
 limitations under the License.
 -->
 <script>
-  // Needed for JSCompiler to understand it's global.
-  // eslint-disable-next-line no-unused-vars, prefer-const
-  let Gerrit = window.Gerrit || {};
-  window.Gerrit = Gerrit;
+  window.Gerrit = window.Gerrit || {};
 </script>
-<link rel="import" href="../styles/themes/app-theme.html">
 <link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
 <link rel="import" href="../elements/diff/gr-diff-cursor/gr-diff-cursor.html">
diff --git a/polygerrit-ui/app/embed/test.html b/polygerrit-ui/app/embed/test.html
index eed2fef..955eaee 100644
--- a/polygerrit-ui/app/embed/test.html
+++ b/polygerrit-ui/app/embed/test.html
@@ -19,8 +19,8 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>Embed Test Runner</title>
 <meta charset="utf-8">
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script>
-  WCT.loadSuites(['embed_test.html']);
+  WCT.loadSuites(['../embed/embed_test.html']);
 </script>
diff --git a/polygerrit-ui/app/embed_test.sh b/polygerrit-ui/app/embed_test.sh
index d482796..0d8f58f 100755
--- a/polygerrit-ui/app/embed_test.sh
+++ b/polygerrit-ui/app/embed_test.sh
@@ -4,20 +4,19 @@
 
 t=$(mktemp -d || mktemp -d -t wct-XXXXXXXXXX)
 components=$TEST_SRCDIR/gerrit/polygerrit-ui/app/test_components.zip
-code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/polygerrit_embed_ui.zip
-index=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html
-tests=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/*_test.html
+code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/pg_code.zip
 
+echo $t
 unzip -qd $t $components
 unzip -qd $t $code
+# Purge test/ directory contents coming from pg_code.zip.
+rm -rf $t/test
 mkdir -p $t/test
-cp $index $t/test/
-cp $tests $t/test/
+cp $TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html $t/test/
 
 if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
     CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
-    # TODO(paladox): Fix Firefox support for headless mode
-    FIREFOX_OPTIONS=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
@@ -62,9 +61,9 @@
     };
 EOF
 
-export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+export PATH="$(dirname $NPM):$PATH"
 
 cd $t
 test -n "${WCT}"
 
-$(basename ${WCT}) ${WCT_ARGS}
+${WCT} ${WCT_ARGS}
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.html b/polygerrit-ui/app/gr-diff/gr-diff-root.html
index 132654c..b3f0d34 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.html
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.html
@@ -1,7 +1,4 @@
 <script>
-  // Needed for JSCompiler to understand it's global.
-  // eslint-disable-next-line no-unused-vars, prefer-const
-  let Gerrit = window.Gerrit || {};
-  window.Gerrit = Gerrit;
+  window.Gerrit = window.Gerrit || {};
 </script>
 <link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 98387a0..7ef0ee3 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -14,17 +14,14 @@
         # See: https://github.com/google/closure-compiler/issues/2042
         compilation_level = "WHITESPACE_ONLY",
         defs = [
-            "--polymer_version=1",
+            "--polymer_version=2",
             "--jscomp_off=duplicate",
-            "--force_inject_library=es6_runtime",
         ],
-        language = "ECMASCRIPT5",
+        language = "ECMASCRIPT_2017",
         deps = [name + "_closure_lib"],
         dependency_mode = "PRUNE_LEGACY",
     )
 
-    # TODO(davido): Remove JSC_REFERENCE_BEFORE_DECLARE when this is fixed upstream:
-    # https://github.com/Polymer/polymer-resin/issues/7
     closure_js_library(
         name = name + "_closure_lib",
         srcs = [appName + ".js"],
@@ -33,9 +30,7 @@
         # and remove this supression
         suppress = [
             "JSC_JSDOC_MISSING_TYPE_WARNING",
-            "JSC_REFERENCE_BEFORE_DECLARE",
             "JSC_UNNECESSARY_ESCAPE",
-            "JSC_UNUSED_LOCAL_ASSIGNMENT",
         ],
         deps = [
             "//lib/polymer_externs:polymer_closure",
diff --git a/polygerrit-ui/app/samples/bind-parameters.html b/polygerrit-ui/app/samples/bind-parameters.html
index a7eb39a..a28c462 100644
--- a/polygerrit-ui/app/samples/bind-parameters.html
+++ b/polygerrit-ui/app/samples/bind-parameters.html
@@ -15,7 +15,7 @@
   <script>
     Polymer({
       is: 'my-bind-sample',
-      _legacyUndefinedCheck: true,
+
       properties: {
         computedExample: {
           type: String,
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
index 0d38c63..d1d96a8 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -32,6 +32,11 @@
       const coverageData = {};
       let displayCoverage = false;
       const annotationApi = plugin.annotationApi();
+      const styleApi = plugin.styles();
+
+      const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
+      const emptyStyle = styleApi.css('');
+
       annotationApi.addLayer(context => {
         if (Object.keys(coverageData).length === 0) {
           // Coverage data is not ready yet.
@@ -41,16 +46,16 @@
         const line = context.line;
         // Highlight lines missing coverage with this background color if
         // coverage should be displayed, else do nothing.
-        const cssClass = displayCoverage
-          ? Gerrit.css('background-color: #EF9B9B')
-          : Gerrit.css('');
+        const annotationStyle = displayCoverage
+          ? coverageStyle
+          : emptyStyle;
         if (coverageData[path] &&
               coverageData[path].changeNum === context.changeNum &&
               coverageData[path].patchNum === context.patchNum) {
           const linesMissingCoverage = coverageData[path].linesMissingCoverage;
           if (linesMissingCoverage.includes(line.afterNumber)) {
-            context.annotateRange(0, line.text.length, cssClass, 'right');
-            context.annotateLineNumber(cssClass, 'right');
+            context.annotateRange(0, line.text.length, annotationStyle, 'right');
+            context.annotateLineNumber(annotationStyle, 'right');
           }
         }
       }).enableToggleCheckbox('Display Coverage', checkbox => {
diff --git a/polygerrit-ui/app/samples/repo-command.html b/polygerrit-ui/app/samples/repo-command.html
index 37aca04..526d350 100644
--- a/polygerrit-ui/app/samples/repo-command.html
+++ b/polygerrit-ui/app/samples/repo-command.html
@@ -29,7 +29,7 @@
   <script>
     Polymer({
       is: 'repo-command-low',
-      _legacyUndefinedCheck: true,
+
       attached() {
         console.log(this.repoName);
         console.log(this.config);
diff --git a/polygerrit-ui/app/samples/some-screen.html b/polygerrit-ui/app/samples/some-screen.html
index 527ebce..da025a2 100644
--- a/polygerrit-ui/app/samples/some-screen.html
+++ b/polygerrit-ui/app/samples/some-screen.html
@@ -38,7 +38,7 @@
   <script>
     Polymer({
       is: 'some-screen-main',
-      _legacyUndefinedCheck: true,
+
       properties: {
         rootUrl: String,
       },
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..6e503c7
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
@@ -0,0 +1,71 @@
+/**
+ * @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.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..0266ab9
--- /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..ffcdba9
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
@@ -0,0 +1,114 @@
+/**
+ * @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;
+  }
+
+  /**
+   * @enum {string}
+   */
+  Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = {
+    REVIEWER: 'reviewers',
+    CC: 'ccs',
+    ANY: 'any',
+  };
+
+  class GrReviewerSuggestionsProvider {
+    static create(restApi, changeNumber, usersType) {
+      switch (usersType) {
+        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
+          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+              input => restApi.getChangeSuggestedReviewers(changeNumber,
+                  input));
+        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
+          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+              input => restApi.getChangeSuggestedCCs(changeNumber, input));
+        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
+          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+              input => restApi.getSuggestedAccounts(
+                  `cansee:${changeNumber} ${input}`));
+        default:
+          throw new Error(`Unknown users type: ${usersType}`);
+      }
+    }
+
+    constructor(restAPI, changeNumber, apiCall) {
+      this._changeNumber = changeNumber;
+      this._apiCall = apiCall;
+      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([]);
+      }
+
+      return this._apiCall(input)
+          .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..ca3c277
--- /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 = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+        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 = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+        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/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 624992b..672c43f 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -75,5 +75,58 @@
     return wrappedPromise;
   };
 
+  /**
+   * Get computed style value.
+   *
+   * If ShadyCSS is provided, use ShadyCSS api.
+   * If `getComputedStyleValue` is provided on the elment, use it.
+   * Otherwise fallback to native method (in polymer 2).
+   *
+   */
+  util.getComputedStyleValue = (name, el) => {
+    let style;
+    if (window.ShadyCSS) {
+      style = ShadyCSS.getComputedStyleValue(el, name);
+    } else if (el.getComputedStyleValue) {
+      style = el.getComputedStyleValue(name);
+    } else {
+      style = getComputedStyle(el).getPropertyValue(name);
+    }
+    return style;
+  };
+
+  /**
+   * Query selector on a dom element.
+   *
+   * This is shadow DOM compatible, but only works when selector is within
+   * one shadow host, won't work if your selector is crossing
+   * multiple shadow hosts.
+   *
+   */
+  util.querySelector = (el, selector) => {
+    let nodes = [el];
+    let element = null;
+    while (nodes.length) {
+      const node = nodes.pop();
+
+      // Skip if it's an invalid node.
+      if (!node || !node.querySelector) continue;
+
+      // Try find it with native querySelector directly
+      element = node.querySelector(selector);
+
+      if (element) {
+        break;
+      } else if (node.shadowRoot) {
+        // If shadowHost detected, add the host and its children
+        nodes = nodes.concat(Array.from(node.children));
+        nodes.push(node.shadowRoot);
+      } else {
+        nodes = nodes.concat(Array.from(node.children));
+      }
+    }
+    return element;
+  };
+
   window.util = util;
 })(window);
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
index ccc17b0..88d50c0 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -34,7 +34,7 @@
       }
       .info {
         display: inline-block;
-        padding: 1em;
+        padding: var(--spacing-l);
         vertical-align: top;
       }
       .info > div > span {
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index 41aec27..c837492 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -34,7 +34,7 @@
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local('Roboto'), local('RobotoMono-Regular'),
+  src: local('Roboto'), local('Roboto-Regular'),
        url('../fonts/Roboto-Regular.woff2') format('woff2'),
        url('../fonts/Roboto-Regular.woff') format('woff');
   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 8f72216..345363b 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -17,9 +17,6 @@
 <dom-module id="gr-change-list-styles">
   <template>
     <style>
-      :host {
-        font-size: var(--font-size-normal);
-      }
       gr-change-list-item,
       tr {
         border-top: 1px solid var(--border-color);
@@ -99,7 +96,7 @@
         text-decoration: underline;
       }
       .cell {
-        height: 2.25rem;
+        padding: var(--spacing-s) 0;
       }
       .star {
         padding: 0;
@@ -123,7 +120,7 @@
         vertical-align: middle;
       }
       .leftPadding {
-        width: var(--default-horizontal-margin);
+        width: var(--spacing-l);
       }
       .star {
         width: 30px;
@@ -165,12 +162,12 @@
       }
       @media only screen and (max-width: 50em) {
         :host {
-          font-size: var(--font-size-large);
+          font-size: var(--font-size-h3);
         }
         gr-change-list-item {
           flex-wrap: wrap;
           justify-content: space-between;
-          padding: .25em .5em;
+          padding: var(--spacing-xs) var(--spacing-m);
         }
         gr-change-list-item[selected],
         gr-change-list-item:focus {
@@ -199,10 +196,10 @@
         }
         .groupHeader .cell,
         .noChanges .cell {
-          padding: 0 .5em;
+          padding: 0 var(--spacing-m);
         }
         .subject {
-          margin-bottom: .25em;
+          margin-bottom: var(--spacing-xs);
           width: calc(100% - 2em);
         }
         .owner,
@@ -214,11 +211,6 @@
           height: auto;
         }
       }
-      @media only screen and (min-width: 1450px) {
-        :host {
-          font-size: 14px;
-        }
-      }
     </style>
   </template>
 </dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
new file mode 100644
index 0000000..fef3872
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
@@ -0,0 +1,48 @@
+<!--
+@license
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-change-metadata-shared-styles">
+  <template>
+    <style include="shared-styles"></style>
+    <style>
+      section {
+        display: table-row;
+      }
+
+      section:not(:first-of-type) .title,
+      section:not(:first-of-type) .value {
+        padding-top: var(--spacing-s);
+      }
+
+      .title,
+      .value {
+        display: table-cell;
+      }
+
+      .title {
+        color: var(--deemphasized-text-color);
+        max-width: 20em;
+        padding-left: var(--metadata-horizontal-padding);
+        padding-right: var(--metadata-horizontal-padding);
+        word-break: break-word;
+      }
+
+      .value {
+        padding-right: var(--metadata-horizontal-padding);
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
new file mode 100644
index 0000000..834f64a
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
@@ -0,0 +1,54 @@
+<!--
+@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.
+-->
+<!--
+  This is shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+-->
+<dom-module id="gr-change-view-integration-shared-styles">
+  <template>
+    <style include="shared-styles"></style>
+    <style>
+      .header {
+        color: var(--primary-text-color);
+        background-color: var(--table-header-background-color);
+        justify-content: space-between;
+        padding: var(--spacing-m) var(--spacing-l);
+        border-bottom: 1px solid var(--border-color);
+      }
+      .header .label {
+        font-weight: var(--font-weight-bold);
+        font-size: var(--font-size-h3);
+        margin: 0 var(--spacing-l) 0 0;
+      }
+      .header .note {
+        color: var(--deemphasized-text-color);
+      }
+      .content {
+        background-color: var(--view-background-color);
+      }
+      .header a,
+      .content a {
+        color: var(--link-color);
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 65c1ae3..3fe0a72 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -27,18 +27,18 @@
       }
       .gr-form-styles h1,
       .gr-form-styles h2 {
-        margin-bottom: .3em;
+        margin-bottom: var(--spacing-s);
       }
       .gr-form-styles h4 {
         font-weight: var(--font-weight-bold);
       }
       .gr-form-styles fieldset {
         border: none;
-        margin-bottom: 2em;
+        margin-bottom: var(--spacing-xxl);
       }
       .gr-form-styles section {
         display: flex;
-        margin: .25em 0;
+        margin: var(--spacing-s) 0;
         min-height: 2em;
       }
       .gr-form-styles section * {
@@ -51,12 +51,9 @@
       .gr-form-styles .title {
         color: var(--deemphasized-text-color);
         font-weight: var(--font-weight-bold);
-        padding-right: .5em;
+        padding-right: var(--spacing-m);
         width: 15em;
       }
-      .gr-form-styles iron-autogrow-textarea {
-        font-size: var(--font-size-normal);
-      }
       .gr-form-styles th {
         color: var(--deemphasized-text-color);
         text-align: left;
@@ -65,7 +62,7 @@
       .gr-form-styles td,
       .gr-form-styles tfoot th {
         height: 2em;
-        padding: .25em 0;
+        padding: var(--spacing-s) 0;
         vertical-align: middle;
       }
       .gr-form-styles .emptyHeader {
@@ -86,10 +83,9 @@
       .gr-form-styles select,
       .gr-form-styles textarea {
         border: 1px solid var(--border-color);
-        border-radius: 2px;
-        font-size: var(--font-size-normal);
+        border-radius: var(--border-radius);
         height: 2em;
-        padding: 0 .15em;
+        padding: 0 var(--spacing-xs);
       }
       .gr-form-styles td:last-child {
         width: 5em;
@@ -104,26 +100,24 @@
         min-height: 2em;
         --iron-autogrow-textarea: {
           border: 1px solid var(--border-color);
-          border-radius: 2px;
+          border-radius: var(--border-radius);
           box-sizing: border-box;
-          font-size: var(--font-size-normal);
-          padding: .25em .15em 0 .15em;
+          padding: var(--spacing-s) var(--spacing-xs) 0 var(--spacing-xs);
         }
       }
       .gr-form-styles gr-autocomplete {
         border: none;
         --gr-autocomplete: {
           border: 1px solid var(--border-color);
-          border-radius: 2px;
-          font-size: var(--font-size-normal);
+          border-radius: var(--border-radius);
           height: 2em;
-          padding: 0 .15em;
+          padding: 0 var(--spacing-xs);
           width: 14em;
         }
       }
       @media only screen and (max-width: 40em) {
         .gr-form-styles section {
-          margin-bottom: 1em;
+          margin-bottom: var(--spacing-l);
         }
         .gr-form-styles .title,
         .gr-form-styles .value {
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.html b/polygerrit-ui/app/styles/gr-menu-page-styles.html
index 48ca396..d3b95b8 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.html
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.html
@@ -22,12 +22,12 @@
         display: block;
       }
       main {
-        margin: 2em auto;
+        margin: var(--spacing-xxl) auto;
         max-width: 50em;
       }
       .mainHeader {
         margin-left: 14em;
-        padding: 1em 0 1em 2em;
+        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
       }
       main.table,
       .mainHeader {
@@ -42,11 +42,11 @@
       }
       .loading {
         color: var(--deemphasized-text-color);
-        padding: 1em var(--default-horizontal-margin);
+        padding: var(--spacing-l);
       }
       @media only screen and (max-width: 67em) {
         main {
-          margin: 2em 0 2em 15em;
+          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
         }
         main.table {
           margin-left: 14em;
@@ -54,17 +54,17 @@
       }
       @media only screen and (max-width: 53em) {
         .loading {
-          padding: 0 var(--default-horizontal-margin);
+          padding: 0 var(--spacing-l);
         }
         main {
-          margin: 2em 1em;
+          margin: var(--spacing-xxl) var(--spacing-l);
         }
         main.table {
           margin: 0;
         }
         .mainHeader {
           margin-left: 0;
-          padding: .5em 0 .5em 1em;
+          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
         }
       }
     </style>
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index 18ec143..ced6ecb 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -18,13 +18,13 @@
   <template>
     <style>
       .navStyles ul {
-        padding: 1em 0;
+        padding: var(--spacing-l) 0;
       }
       .navStyles li {
         border-bottom: 1px solid transparent;
         border-top: 1px solid transparent;
         display: block;
-        padding: 0 calc(var(--default-horizontal-margin) + 0.5em);
+        padding: 0 var(--spacing-xl);
       }
       .navStyles li a {
         display: block;
@@ -33,20 +33,20 @@
         white-space: nowrap;
       }
       .navStyles .subsectionItem {
-        padding-left: calc(var(--default-horizontal-margin) + 1.5em);
+        padding-left: var(--spacing-xxl);
       }
       .navStyles .hideSubsection {
         display: none;
       }
       .navStyles li.sectionTitle {
-        padding: 0 2em 0 var(--default-horizontal-margin);
+        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
       }
       .navStyles li.sectionTitle:not(:first-child) {
-        margin-top: 1em;
+        margin-top: var(--spacing-l);
       }
       .navStyles .title {
         font-weight: var(--font-weight-bold);
-        margin: .4em 0;
+        margin: var(--spacing-s) 0;
       }
       .navStyles .selected {
         background-color: var(--view-background-color);
@@ -57,7 +57,7 @@
       .navStyles a {
         color: var(--primary-text-color);
         display: inline-block;
-        margin: .4em 0;
+        margin: var(--spacing-s) 0;
       }
     </style>
   </template>
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.html b/polygerrit-ui/app/styles/gr-subpage-styles.html
index 098a604..222c38b 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.html
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.html
@@ -18,7 +18,7 @@
   <template>
     <style>
       main {
-        margin: 1em 1em;
+        margin: var(--spacing-l);
       }
       .loading {
         display: none;
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
index 1308952..d4e4bcf 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -24,7 +24,7 @@
       }
       .genericList td {
         height: 2.25rem;
-        padding: .3rem 0;
+        padding: var(--spacing-s) 0;
         vertical-align: middle;
       }
       .genericList tr {
@@ -38,11 +38,11 @@
       }
       .genericList th,
       .genericList td {
-        padding-right: 1rem;
+        padding-right: var(--spacing-l);
       }
       .genericList tr th:first-of-type,
       .genericList tr td:first-of-type {
-        padding-left: 1rem;
+        padding-left: var(--spacing-l);
       }
       .genericList tr:first-of-type {
         border-top: 1px solid var(--border-color);
@@ -51,7 +51,7 @@
       .genericList tr td:last-of-type {
         border-left: 1px solid var(--border-color);
         text-align: center;
-        padding: 0 1em;
+        padding: 0 var(--spacing-l);
       }
       .genericList tr th.delete,
       .genericList tr td.delete,
@@ -78,7 +78,7 @@
       }
       .genericList .groupHeader {
         background-color: var(--table-subheader-background-color);
-        font-size: var(--font-size-large);
+        font-size: var(--font-size-h3);
       }
       .genericList a {
         color: var(--primary-text-color);
@@ -93,7 +93,7 @@
       .genericList .loadingMsg {
         color: var(--deemphasized-text-color);
         display: block;
-        padding: .3em var(--default-horizontal-margin);
+        padding: var(--spacing-s) var(--spacing-l);
       }
       .genericList .loadingMsg:not(.loading) {
         display: none;
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.html b/polygerrit-ui/app/styles/gr-voting-styles.html
index 3b1ee64..eec79be 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.html
+++ b/polygerrit-ui/app/styles/gr-voting-styles.html
@@ -23,6 +23,7 @@
           border: 1px solid rgba(0,0,0,.12);
           border-radius: 1em;
           box-shadow: none;
+          box-sizing: border-box;
           min-width: 3em;
         }
       }
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
index 618a2d71..4c85176 100644
--- a/polygerrit-ui/app/styles/main.css
+++ b/polygerrit-ui/app/styles/main.css
@@ -26,10 +26,10 @@
   -webkit-text-size-adjust: none;
   /*
    * Default browser fonts are 16px. We want users with default settings to see
-   * a base font of 13px. 13/16 = .8125. This needs to be in html because
+   * a base font of 14px. 14/16 = .875. This needs to be in html because
    * can use rems based on this font-size throughout the app.
    */
-  font-size: .8125em;
+  font-size: .875em;
 }
 html,
 body {
@@ -42,6 +42,8 @@
    * Work around this using font-size and font-family.
    */
   -webkit-text-size-adjust: none;
-  font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  line-height: 1.4;
+  font-family: var(--font-family, ''), 'Roboto', Arial, sans-serif;
+  font-size: var(--font-size-normal, 1rem);
+  line-height: var(--line-height-normal, 1.4);
+  color: var(--primary-text-color, black);
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 78abe3a..5314741 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -17,7 +17,9 @@
 <dom-module id="shared-styles">
   <template>
     <style>
+
       /* CSS reset */
+
       html, body, button, div, span, applet, object, iframe, h1, h2, h3,
       h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
       code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
@@ -33,8 +35,11 @@
         padding: 0;
         vertical-align: baseline;
       }
-      input,
-      iron-autogrow-textarea {
+      *::after,
+      *::before {
+        box-sizing: border-box;
+      }
+      input {
         background-color: inherit;
         border: 1px solid var(--border-color);
         box-sizing: border-box;
@@ -42,6 +47,20 @@
         margin: 0;
         padding: 0;
       }
+      iron-autogrow-textarea {
+        background-color: inherit;
+        color: var(--primary-text-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-sizing: border-box;
+        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+           css rule, which prevents overriding the border color. Clear that. */
+        -webkit-appearance: none;
+
+        --iron-autogrow-textarea: {
+          padding: 4px;
+        };
+      }
       a {
         color: var(--link-color);
       }
@@ -51,9 +70,6 @@
       button {
         font: inherit;
       }
-      body {
-        line-height: 1;
-      }
       ol, ul {
         list-style: none;
       }
@@ -69,25 +85,42 @@
         border-collapse: collapse;
         border-spacing: 0;
       }
-      /* Other Shared Styles*/
-      h1 {
-        font-size: 2rem;
-        font-weight: var(--font-weight-bold);
+
+      /* Fonts */
+
+      .font-normal {
+        font-size: var(--font-size-normal);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-normal);
       }
-      h2 {
-        font-size: 1.5rem;
-        font-weight: var(--font-weight-bold);
+      .font-small {
+        font-size: var(--font-size-small);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-small);
       }
-      h3 {
-        font-size: 1.17em;
+      h1, .font-h1 {
+        font-size: var(--font-size-h1);
         font-weight: var(--font-weight-bold);
+        line-height: var(--line-height-h1);
+      }
+      h2, .font-h2 {
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-bold);
+        line-height: var(--line-height-h2);
+      }
+      h3, .font-h3 {
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-bold);
+        line-height: var(--line-height-h3);
       }
       iron-icon {
         color: var(--deemphasized-text-color);
         --iron-icon-height: 20px;
         --iron-icon-width: 20px;
       }
+
       /* Stopgap solution until we remove hidden$ attributes. */
+
       [hidden] {
         display: none !important;
       }
@@ -103,12 +136,19 @@
         --paper-toggle-button-checked-bar-color: var(--link-color);
         --paper-toggle-button-checked-button-color: var(--link-color);
       }
+      paper-tabs {
+        --paper-tab-content-focused: {
+          /* paper-tabs uses 700 here, which can look awkward */
+          font-weight: var(--font-weight-normal);
+        };
+        --paper-tab-content-unselected: {
+          /* paper-tabs uses 0.8 here, but we want to control the color directly */
+          opacity: 1;
+          color: var(--deemphasized-text-color);
+        };      }
       strong {
         font-weight: var(--font-weight-bold);
       }
-      :host {
-        color: var(--primary-text-color);
-      }
     </style>
   </template>
 </dom-module>
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
index ec47c53..bb477c2 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ b/polygerrit-ui/app/styles/themes/app-theme.html
@@ -16,139 +16,167 @@
 -->
 <custom-style><style is="custom-style">
 html {
-  /* Following vars have LTS for plugin API. */
-  --primary-text-color: #000;
-  /* For backwords compatibility we keep this as --header-background-color. */
-  --header-background-color: #eee;
-  --header-title-content: 'Gerrit';
-  --header-icon: none;
-  --header-icon-size: 0em;
-  --header-text-color: #000;
-  --header-title-font-size: 1.75rem;
-  --footer-background-color: #eee;
-  --border-color: #ddd;
-  /* This allows to add a border in custom themes */
-  --header-border-bottom: 1px solid var(--border-color);
-  --header-border-image: '';
-  --footer-border-top: 1px solid var(--border-color);
+  /**
+   * When adding a new color variable make sure to also add it to the other
+   * theme files in the same directory.
+   *
+   * For colors prefer lower case hex colors.
+   *
+   * Note that plugins might be using these variables, so removing a variable
+   * can be a breaking change that should go into the release notes.
+   */
 
-  /* Following are not part of plugin API. */
-  --selection-background-color: rgba(161, 194, 250, 0.1);
-  --hover-background-color: rgba(161, 194, 250, 0.2);
-  --expanded-background-color: #eee;
-  --view-background-color: #fff;
-  --default-horizontal-margin: 1rem;
-
-  --deemphasized-text-color: #757575;
-  /* Used on text color for change list that doesn't need user's attention. */
-  --reviewed-text-color: var(--primary-text-color);
-  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  /* Used on text for change list that needs user's attention. */
-  --font-weight-bold: 500;
-  --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
-  --iron-overlay-backdrop: {
-    transition: none;
-  }
-  --table-header-background-color: #fafafa;
-  --table-subheader-background-color: #eaeaea;
-
-  --chip-background-color: #eee;
-
-  --dropdown-background-color: #fff;
-
-  --select-background-color: rgb(248, 248, 248);
-
-  --assignee-highlight-color: #fcfad6;
-
-  /* Font sizes */
-  --font-size-normal: 1rem;
-  --font-size-small: .92rem;
-  --font-size-large: 1.154rem;
-
+  /* text colors */
+  --primary-text-color: black;
   --link-color: #2a66d9;
-  --primary-button-background-color: var(--link-color);
-  --primary-button-text-color: #fff;
-  --secondary-button-background-color: #fff;
-  --secondary-button-text-color: #212121;
-  --default-button-background-color: #fff;
-  --default-button-text-color: var(--link-color);
-  --dialog-background-color: #fff;
-
-  /* Used for both the old patchset header and for indicating that a particular
-    change message was selected. */
-  --emphasis-color: #fff9c4;
-
+  --comment-text-color: black;
+  --deemphasized-text-color: #5F6368;
+  --default-button-text-color: #2a66d9;
   --error-text-color: red;
+  --primary-button-text-color: white;
+    /* Used on text color for change list that doesn't need user's attention. */
+  --reviewed-text-color: black;
+  --secondary-button-text-color: #212121;
+  --tooltip-text-color: white;
+  --vote-text-color-recommended: #388e3c;
+  --vote-text-color-disliked: #d32f2f;
 
-  --vote-color-approved: #9fcc6b;
-  --vote-color-recommended: #c9dfaf;
-  --vote-color-rejected: #f7a1ad;
-  --vote-color-disliked: #f7c4cb;
-  --vote-color-neutral: #ebf5fb;
-
-  --vote-text-color-recommended: #388E3C;
-  --vote-text-color-disliked: #D32F2F;
-
-  /* Diff colors */
-  --diff-selection-background-color: #c7dbf9;
-  --light-remove-highlight-color: #FFEBEE;
-  --light-add-highlight-color: #D8FED8;
-  --light-remove-add-highlight-color: #FFF8DC;
-  --light-rebased-add-highlight-color: #EEEEFF;
-  --dark-remove-highlight-color: #FFCDD2;
-  --dark-add-highlight-color: #AAF2AA;
-  --dark-rebased-remove-highlight-color: #F7E8B7;
-  --dark-rebased-add-highlight-color: #D7D7F9;
-  --diff-context-control-color: #fff7d4;
-  --diff-context-control-border-color: #f6e6a5;
-  --diff-tab-indicator-color: var(--deemphasized-text-color);
-  --diff-trailing-whitespace-indicator: #ff9ad2;
-  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
-  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
-
+  /* background colors */
+  --assignee-highlight-color: #fcfad6;
+  --chip-background-color: #eee;
+  --comment-background-color: #fcfad6;
+  --default-button-background-color: white;
+  --dialog-background-color: white;
+  --dropdown-background-color: white;
+  --edit-mode-background-color: #ebf5fb;
+  --emphasis-color: #fff9c4;
+  --expanded-background-color: #eee;
+  --hover-background-color: rgba(161, 194, 250, 0.2);
+  --primary-button-background-color: #2a66d9;
+  --secondary-button-background-color: white;
+  --select-background-color: #f8f8f8;
+  --selection-background-color: rgba(161, 194, 250, 0.1);
   --shell-command-background-color: #f5f5f5;
   --shell-command-decoration-background-color: #ebebeb;
-
-  --comment-text-color: #000;
-  --comment-background-color: #fcfad6;
-  --unresolved-comment-background-color: #fcfaa6;
-
-  --edit-mode-background-color: #ebf5fb;
-
+  --table-header-background-color: #fafafa;
+  --table-subheader-background-color: #eaeaea;
   --tooltip-background-color: #333;
-  --tooltip-text-color: #fff;
+  --unresolved-comment-background-color: #fcfaa6;
+  --view-background-color: white;
+  --vote-color-approved: #9fcc6b;
+  --vote-color-disliked: #f7c4cb;
+  --vote-color-neutral: #ebf5fb;
+  --vote-color-recommended: #c9dfaf;
+  --vote-color-rejected: #f7a1ad;
 
-  --syntax-default-color: var(--primary-text-color);
-  --syntax-attribute-color: var(--primary-text-color);
-  --syntax-function-color: var(--primary-text-color);
-  --syntax-meta-color: #FF1717;
-  --syntax-keyword-color: #9E0069;
-  --syntax-number-color: #164;
-  --syntax-selector-class-color: #164;
-  --syntax-variable-color: black;
-  --syntax-template-variable-color: #0000C0;
-  --syntax-comment-color: #3F7F5F;
-  --syntax-string-color: #2A00FF;
-  --syntax-selector-id-color: #2A00FF;
-  --syntax-built_in-color: #30a;
-  --syntax-tag-color: #170;
-  --syntax-link-color: #219;
-  --syntax-meta-keyword-color: #219;
-  --syntax-type-color: var(--color-link);
-  --syntax-title-color: #0000C0;
+  /* misc colors */
+  --border-color: #ddd;
+
+  /* fonts */
+  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
+  --font-size-code: 12px;     /* 12px mono */
+  --font-size-mono: .929rem;  /* 13px mono */
+  --font-size-small: .857rem; /* 12px */
+  --font-size-normal: 1rem;   /* 14px */
+  --font-size-h3: 1.143rem;   /* 16px */
+  --font-size-h2: 1.429rem;   /* 20px */
+  --font-size-h1: 1.714rem;   /* 24px */
+  --line-height-code: 1.334;      /* 16px */
+  --line-height-mono: 1.286rem;   /* 18px */
+  --line-height-small: 1.143rem;  /* 16px */
+  --line-height-normal: 1.429rem; /* 20px */
+  --line-height-h3: 1.714rem;     /* 24px */
+  --line-height-h2: 2rem;         /* 28px */
+  --line-height-h1: 2.286rem;     /* 32px */
+  --font-weight-normal: 400;
+  --font-weight-bold: 500;
+
+  /* spacing */
+  --spacing-xxs: 1px;
+  --spacing-xs: 2px;
+  --spacing-s: 4px;
+  --spacing-m: 8px;
+  --spacing-l: 12px;
+  --spacing-xl: 16px;
+  --spacing-xxl: 24px;
+
+  /* header and footer */
+  --footer-background-color: #eee;
+  --footer-border-top: 1px solid var(--border-color);
+  --header-background-color: #eee;
+  --header-border-bottom: 1px solid var(--border-color);
+  --header-border-image: '';
+  --header-box-shadow: none;
+  --header-padding: 0 var(--spacing-l);
+  --header-icon-size: 0em;
+  --header-icon: none;
+  --header-text-color: black;
+  --header-title-content: 'Gerrit';
+  --header-title-font-size: 1.75rem;
+
+  /* diff colors */
+  --dark-add-highlight-color: #aaf2aa;
+  --dark-rebased-add-highlight-color: #d7d7f9;
+  --dark-rebased-remove-highlight-color: #f7e8b7;
+  --dark-remove-highlight-color: #ffcdd2;
+  --diff-blank-background-color: white;
+  --diff-context-control-background-color: #fff7d4;
+  --diff-context-control-border-color: #f6e6a5;
+  --diff-context-control-color: var(--deemphasized-text-color);
+  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
+  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+  --diff-selection-background-color: #c7dbf9;
+  --diff-tab-indicator-color: var(--deemphasized-text-color);
+  --diff-trailing-whitespace-indicator: #ff9ad2;
+  --light-add-highlight-color: #d8fed8;
+  --light-rebased-add-highlight-color: #eef;
+  --light-remove-add-highlight-color: #fff8dc;
+  --light-remove-highlight-color: #ffebee;
+
+  /* syntax colors */
   --syntax-attr-color: #219;
-  --syntax-literal-color: #219;
-  --syntax-selector-pseudo-color: #FA8602;
-  --syntax-regexp-color: #FA8602;
-  --syntax-selector-attr-color: #FA8602;
-  --syntax-template-tag-color: #FA8602;
-  --syntax-params-color: var(--primary-text-color);
+  --syntax-attribute-color: var(--primary-text-color);
+  --syntax-built_in-color: #30a;
+  --syntax-comment-color: #3f7f5f;
+  --syntax-default-color: var(--primary-text-color);
   --syntax-doctag-weight: bold;
+  --syntax-function-color: var(--primary-text-color);
+  --syntax-keyword-color: #9e0069;
+  --syntax-link-color: #219;
+  --syntax-literal-color: #219;
+  --syntax-meta-color: #ff1717;
+  --syntax-meta-keyword-color: #219;
+  --syntax-number-color: #164;
+  --syntax-params-color: var(--primary-text-color);
+  --syntax-regexp-color: #fa8602;
+  --syntax-selector-attr-color: #fa8602;
+  --syntax-selector-class-color: #164;
+  --syntax-selector-id-color: #2a00ff;
+  --syntax-selector-pseudo-color: #fa8602;
+  --syntax-string-color: #2a00ff;
+  --syntax-tag-color: #170;
+  --syntax-template-tag-color: #fa8602;
+  --syntax-template-variable-color: #0000c0;
+  --syntax-title-color: #0000c0;
+  --syntax-type-color: #2a66d9;
+  --syntax-variable-color: var(--primary-text-color);
+  /* misc */
+  --border-radius: 4px;
   --reply-overlay-z-index: 1000;
+  --iron-overlay-backdrop: {
+    transition: none;
+  };
 }
 @media screen and (max-width: 50em) {
   html {
-    --default-horizontal-margin: .7rem;
+    --spacing-xxs: 1px;
+    --spacing-xs: 1px;
+    --spacing-s: 2px;
+    --spacing-m: 4px;
+    --spacing-l: 8px;
+    --spacing-xl: 12px;
+    --spacing-xxl: 16px;
   }
 }
 </style></custom-style>
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 718ac25..957cc25 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -17,97 +17,124 @@
 <dom-module id="dark-theme">
   <custom-style><style is="custom-style">
     html {
-      --primary-text-color: #e2e2e2;
-      --view-background-color: #212121;
-      --border-color: #555555;
-      --header-border-bottom: 1px solid var(--border-color);
-      --header-border-image: '';
-      --footer-border-bottom: 1px solid var(--border-color);
-      --table-header-background-color: #353637;
-      --table-subheader-background-color: rgb(19, 20, 22);
-      --header-background-color: #3c4043;
-      --header-text-color: var(--primary-text-color);
-      --deemphasized-text-color: #9a9a9a;
-      /* Used on text color for change list doesn't need user's attention. */
-      --reviewed-text-color: #DADCE0;
-      /* Used on text for change list that needs user's attention. */
-      --font-weight-bold: 900;
-      --footer-background-color: var(--table-header-background-color);
-      --expanded-background-color: #26282b;
-      --link-color: #5487E5;
-      --primary-button-background-color: var(--link-color);
-      --primary-button-text-color: var(--primary-text-color);
-      --secondary-button-background-color: var(--primary-text-color);
-      --secondary-button-text-color: var(--deemphasized-text-color);
-      --default-button-text-color: var(--link-color);
-      --default-button-background-color: var(--table-subheader-background-color);
-      --dropdown-background-color: var(--table-header-background-color);
-      --dialog-background-color: var(--view-background-color);
-      --chip-background-color: var(--table-header-background-color);
-      --header-title-font-size: 1.75rem;
+      /**
+       * Sections and variables must stay consistent with app-theme.html.
+       *
+       * Only modify color variables in this theme file. dark-theme extends
+       * app-theme, so there is no need to repeat all variables, but for colors
+       * it does make sense to list them all: If you override one color, then
+       * you probably want to override all.
+       */
 
-      --select-background-color: var(--table-subheader-background-color);
-
-      --assignee-highlight-color: rgb(58, 54, 28);
-
-      --diff-selection-background-color: #3A71D8;
-      --light-remove-highlight-color: #320404;
-      --light-add-highlight-color: #0F401F;
-      --light-remove-add-highlight-color: #2f3f2f;
-      --light-rebased-remove-highlight-color: rgb(60, 37, 8);
-      --light-rebased-add-highlight-color: rgb(72, 113, 101);
-      --dark-remove-highlight-color: #62110F;
-      --dark-add-highlight-color: #133820;
-      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
-      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-      --diff-context-control-color: var(--table-header-background-color);
-      --diff-context-control-border-color: var(--border-color);
-      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
-      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
-      --shell-command-background-color: #5f5f5f;
-      --shell-command-decoration-background-color: #999999;
+      /* text colors */
+      --primary-text-color: #e8eaed;
+      --link-color: #8ab4f8;
       --comment-text-color: var(--primary-text-color);
-      --comment-background-color: #0B162B;
-      --unresolved-comment-background-color: rgb(56, 90, 154);
+      --deemphasized-text-color: #9e9e9e;
+      --default-button-text-color: #8ab4f8;
+      --error-text-color: red;
+      --primary-button-text-color: var(--primary-text-color);
+        /* Used on text color for change list doesn't need user's attention. */
+      --reviewed-text-color: #dadce0;
+      --secondary-button-text-color: var(--deemphasized-text-color);
+      --tooltip-text-color: white;
+      --vote-text-color-recommended: #388e3c;
+      --vote-text-color-disliked: #d32f2f;
 
-      --vote-color-approved: rgb(127, 182, 107);
-      --vote-color-recommended: rgb(63, 103, 50);
-      --vote-color-rejected: #ac2d3e;
+      /* background colors */
+      --assignee-highlight-color: #3a361c;
+      --chip-background-color: #131416;
+      --comment-background-color: #0b162b;
+      --default-button-background-color: #3c4043;
+      --dialog-background-color: #131416;
+      --dropdown-background-color: #131416;
+      --edit-mode-background-color: #5c0a36;
+      --emphasis-color: #383f4a;
+      --expanded-background-color: #26282b;
+      --hover-background-color: rgba(161, 194, 250, 0.2);
+      --primary-button-background-color: var(--link-color);
+      --secondary-button-background-color: var(--primary-text-color);
+      --select-background-color: #3c4043;
+      --selection-background-color: rgba(161, 194, 250, 0.1);
+      --shell-command-background-color: #5f5f5f;
+      --shell-command-decoration-background-color: #999;
+      --table-header-background-color: #131416;
+      --table-subheader-background-color: rgba(158, 158, 158, 0.24);
+      --tooltip-background-color: #111;
+      --unresolved-comment-background-color: #385a9a;
+      --view-background-color: #131416;
+      --vote-color-approved: #7fb66b;
       --vote-color-disliked: #bf6874;
       --vote-color-neutral: #597280;
+      --vote-color-recommended: #3f6732;
+      --vote-color-rejected: #ac2d3e;
 
-      --edit-mode-background-color: rgb(92, 10, 54);
-      --emphasis-color: #383f4a;
+      /* misc colors */
+      --border-color: #5f6368;
 
-      --tooltip-background-color: #111;
+      /* fonts */
+      --font-weight-bold: 900;
 
-      --syntax-default-color: var(--primary-text-color);
-      --syntax-meta-color: #6D7EEE;
-      --syntax-keyword-color: #CD4CF0;
-      --syntax-number-color: #00998A;
-      --syntax-selector-class-color: #FFCB68;
-      --syntax-variable-color: #F77669;
-      --syntax-template-variable-color: #F77669;
+      /* spacing */
+
+      /* header and footer */
+      --footer-background-color: #131416;
+      --footer-border-top: 1px solid var(--border-color);
+      --header-background-color: #3c4043;
+      --header-border-bottom: 1px solid var(--border-color);
+      --header-padding: 0 var(--spacing-l);
+      --header-text-color: var(--primary-text-color);
+
+      /* diff colors */
+      --dark-add-highlight-color: #133820;
+      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
+      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+      --dark-remove-highlight-color: #62110f;
+      --diff-blank-background-color: #212121;
+      --diff-context-control-background-color: #131416;
+      --diff-context-control-border-color: var(--border-color);
+      --diff-context-control-color: var(--deemphasized-text-color);
+      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
+      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --diff-selection-background-color: #3a71d8;
+      --diff-tab-indicator-color: var(--deemphasized-text-color);
+      --diff-trailing-whitespace-indicator: #ff9ad2;
+      --light-add-highlight-color: #0f401f;
+      --light-rebased-add-highlight-color: #487165;
+      --light-remove-add-highlight-color: #2f3f2f;
+      --light-remove-highlight-color: #320404;
+
+      /* syntax colors */
+      --syntax-attr-color: #80cbbf;
+      --syntax-attribute-color: var(--primary-text-color);
+      --syntax-built_in-color: #f7c369;
       --syntax-comment-color: var(--deemphasized-text-color);
-      --syntax-string-color: #C3E88D;
-      --syntax-selector-id-color: #F77669;
-      --syntax-built_in-color: rgb(247, 195, 105);
-      --syntax-tag-color: #F77669;
-      --syntax-link-color: #C792EA;
-      --syntax-meta-keyword-color: #EEFFF7;
-      --syntax-type-color: #DD5F5F;
-      --syntax-title-color: #75A5FF;
-      --syntax-attr-color: #80CBBF;
-      --syntax-literal-color: #EEFFF7;
-      --syntax-selector-pseudo-color: #C792EA;
-      --syntax-regexp-color: #F77669;
-      --syntax-selector-attr-color: #80CBBF;
-      --syntax-template-tag-color: #C792EA;
+      --syntax-default-color: var(--primary-text-color);
       --syntax-doctag-weight: bold;
+      --syntax-function-color: var(--primary-text-color);
+      --syntax-keyword-color: #cd4cf0;
+      --syntax-link-color: #c792ea;
+      --syntax-literal-color: #eefff7;
+      --syntax-meta-color: #6d7eee;
+      --syntax-meta-keyword-color: #eefff7;
+      --syntax-number-color: #00998a;
       --syntax-params-color: var(--primary-text-color);
+      --syntax-regexp-color: #f77669;
+      --syntax-selector-attr-color: #80cbbf;
+      --syntax-selector-class-color: #ffcb68;
+      --syntax-selector-id-color: #f77669;
+      --syntax-selector-pseudo-color: #c792ea;
+      --syntax-string-color: #c3e88d;
+      --syntax-tag-color: #f77669;
+      --syntax-template-tag-color: #c792ea;
+      --syntax-template-variable-color: #f77669;
+      --syntax-title-color: #75a5ff;
+      --syntax-type-color: #dd5f5f;
+      --syntax-variable-color: #f77669;
 
-      --reply-overlay-z-index: 1000;
+      /* misc */
 
+      /* rules applied to <html> */
       background-color: var(--view-background-color);
     }
   </style></custom-style>
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 3de6227..d715d7d 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -1,45 +1,6 @@
 const fs = require('fs');
 const twinkie = require('fried-twinkie');
 
-/**
- * For the purposes of template type checking, externs should be added for
- * anything set on the window object. Note that sub-properties of these
- * declared properties are considered something separate.
- *
- * @todo (beckysiegel) Gerrit's class definitions should be recognized in
- *    closure types.
- */
-const EXTERN_NAMES = [
-  'Gerrit',
-  'GrAnnotation',
-  'GrAttributeHelper',
-  'GrChangeActionsInterface',
-  'GrChangeReplyInterface',
-  'GrDiffBuilder',
-  'GrDiffBuilderImage',
-  'GrDiffBuilderSideBySide',
-  'GrDiffBuilderUnified',
-  'GrDiffGroup',
-  'GrDiffLine',
-  'GrDomHooks',
-  'GrEditConstants',
-  'GrEtagDecorator',
-  'GrFileListConstants',
-  'GrGapiAuth',
-  'GrGerritAuth',
-  'GrLinkTextParser',
-  'GrPluginEndpoints',
-  'GrPopupInterface',
-  'GrRangeNormalizer',
-  'GrReporting',
-  'GrReviewerUpdatesParser',
-  'GrCountStringFormatter',
-  'GrThemeApi',
-  'moment',
-  'page',
-  'util',
-];
-
 fs.readdir('./polygerrit-ui/temp/behaviors/', (err, data) => {
   if (err) {
     console.log('error /polygerrit-ui/temp/behaviors/ directory');
@@ -87,30 +48,39 @@
     mappings = mappingSpecificFile;
   }
 
-  additionalSources.push({
-    path: 'custom-externs.js',
-    src: '/** @externs */' +
-        EXTERN_NAMES.map( name => { return `var ${name};`; }).join(' '),
-  });
-
-  const toCheck = [];
-  for (key of Object.keys(mappings)) {
-    if (mappings[key].html && mappings[key].js) {
-      toCheck.push({
-        htmlSrcPath: mappings[key].html,
-        jsSrcPath: mappings[key].js,
-        jsModule: 'polygerrit.' + mappings[key].package,
+  /**
+   * Types in Gerrit.
+   * All types should be under `./polygerrit-ui/app/types` folder and end with `js`.
+   */
+  fs.readdir('./polygerrit-ui/app/types/', (err, typeFiles) => {
+    for (const typeFile of typeFiles) {
+      if (!typeFile.endsWith('.js')) continue;
+      additionalSources.push({
+        path: `./polygerrit-ui/app/types/${typeFile}`,
+        src: fs.readFileSync(
+            `./polygerrit-ui/app/types/${typeFile}`, 'utf-8'),
       });
     }
-  }
 
-  twinkie.checkTemplate(toCheck, additionalSources)
-      .then(() => {}, joinedErrors => {
-        if (joinedErrors) {
+    const toCheck = [];
+    for (key of Object.keys(mappings)) {
+      if (mappings[key].html && mappings[key].js) {
+        toCheck.push({
+          htmlSrcPath: mappings[key].html,
+          jsSrcPath: mappings[key].js,
+          jsModule: 'polygerrit.' + mappings[key].package,
+        });
+      }
+    }
+
+    twinkie.checkTemplate(toCheck, additionalSources)
+        .then(() => {}, joinedErrors => {
+          if (joinedErrors) {
+            process.exit(1);
+          }
+        }).catch(e => {
+          console.error(e);
           process.exit(1);
-        }
-      }).catch(e => {
-        console.error(e);
-        process.exit(1);
-      });
+        });
+  });
 });
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index a549dd4..c1d8bbd 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -17,7 +17,7 @@
 -->
 
 <link rel="import"
-    href="../bower_components/polymer-resin/standalone/polymer-resin.html" />
+    href="/bower_components/polymer-resin/standalone/polymer-resin.html" />
 <link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
@@ -53,13 +53,13 @@
   (function() {
     setup(() => {
       if (!window.Gerrit) { return; }
-      if (Gerrit._resetPlugins) {
-        Gerrit._resetPlugins();
+      if (Gerrit._testOnly_resetPlugins) {
+        Gerrit._testOnly_resetPlugins();
       }
     });
   })();
 </script>
 <link rel="import"
-    href="../bower_components/iron-test-helpers/iron-test-helpers.html" />
+    href="/bower_components/iron-test-helpers/iron-test-helpers.html" />
 <link rel="import" href="test-router.html" />
-<script src="../bower_components/moment/moment.js"></script>
+<script src="/bower_components/moment/moment.js"></script>
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
new file mode 100644
index 0000000..7ceff7e
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -0,0 +1,26 @@
+/**
+ * @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.
+ */
+
+/**
+ * Helps looking up the proper iron-input element during the Polymer 2
+ * transition. Polymer 2 uses the <iron-input> element, while Polymer 1 uses
+ * the nested <input is="iron-input"> element.
+ */
+window.ironInput = function(element) {
+  return Polymer.dom(element).querySelector(
+      Polymer.Element ? 'iron-input' : 'input[is=iron-input]');
+};
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index cd3aaec..cca7d04 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -19,10 +19,11 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>Elements Test Runner</title>
 <meta charset="utf-8">
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<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,8 @@
     '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',
+    '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',
@@ -99,16 +99,18 @@
     'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
-    'core/gr-reporting/gr-jank-detector_test.html',
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-router/gr-router_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
     '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-builder/gr-diff-builder-unified_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',
+    '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',
@@ -125,7 +127,9 @@
     'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
     'edit/gr-editor-view/gr-editor-view_test.html',
     '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',
+    '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',
@@ -134,7 +138,9 @@
     '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',
+    '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 +155,9 @@
     'settings/gr-ssh-editor/gr-ssh-editor_test.html',
     'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
     'shared/gr-event-interface/gr-event-interface_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 +169,47 @@
     '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-api-utils_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-js-api-interface/gr-gerrit_test.html',
+    'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
+    'shared/gr-js-api-interface/gr-plugin-loader_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',
+    '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',
     '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',
@@ -198,7 +220,6 @@
   for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
-    testFiles.push(file + '?dom=shadow');
   }
 
   // Behaviors tests.
@@ -212,8 +233,9 @@
     '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',
+    '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',
@@ -227,5 +249,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/polygerrit-ui/app/types/custom-externs.js b/polygerrit-ui/app/types/custom-externs.js
new file mode 100644
index 0000000..afa094c
--- /dev/null
+++ b/polygerrit-ui/app/types/custom-externs.js
@@ -0,0 +1,63 @@
+/**
+ * @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.
+ */
+
+/**
+ * For the purposes of template type checking, externs should be added for
+ * anything set on the window object. Note that sub-properties of these
+ * declared properties are considered something separate.
+ *
+ * This file is only for template type checking, not used in Gerrit code.
+ */
+
+/* eslint-disable no-var */
+/* eslint-disable no-unused-vars */
+/** @externs */
+// @unused
+
+var Gerrit;
+var GrAnnotation;
+var GrAttributeHelper;
+var GrChangeActionsInterface;
+var GrChangeReplyInterface;
+var GrDiffBuilder;
+var GrDiffBuilderImage;
+var GrDiffBuilderSideBySide;
+var GrDiffBuilderUnified;
+var GrDiffGroup;
+var GrDiffLine;
+var GrDomHooks;
+var GrEditConstants;
+var GrEtagDecorator;
+var GrFileListConstants;
+var GrGapiAuth;
+var GrGerritAuth;
+var GrLinkTextParser;
+var GrPluginEndpoints;
+var GrPopupInterface;
+var GrRangeNormalizer;
+var GrReporting;
+var GrReviewerUpdatesParser;
+var GrCountStringFormatter;
+var GrThemeApi;
+var SiteBasedCache;
+var FetchPromisesCache;
+var GrRestApiHelper;
+var GrDisplayNameUtils;
+var GrReviewerSuggestionsProvider;
+var moment;
+var page;
+var util;
\ No newline at end of file
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
new file mode 100644
index 0000000..e17bec8
--- /dev/null
+++ b/polygerrit-ui/app/types/types.js
@@ -0,0 +1,279 @@
+/**
+ * @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.
+ */
+
+// Type definitions used across multiple files in Gerrit
+
+window.Gerrit = window.Gerrit || {};
+
+/** @enum {string} */
+Gerrit.CoverageType = {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED: 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED: 'NOT_COVERED',
+  PARTIALLY_COVERED: 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
+};
+
+/**
+ * @typedef {{
+ *   start_line: number,
+ *   start_character: number,
+ *   end_line: number,
+ *   end_character: number,
+ * }}
+ */
+Gerrit.Range;
+
+/**
+ * @typedef {{side: string, range: Gerrit.Range, hovering: boolean}}
+ */
+Gerrit.HoveredRange;
+
+/**
+ * @typedef {{
+ *   side: string,
+ *   type: Gerrit.CoverageType,
+ *   code_range: Gerrit.Range,
+ * }}
+ */
+Gerrit.CoverageRange;
+
+/**
+ * @typedef {{
+ *    basePatchNum: (string|number),
+ *    patchNum: (number),
+ * }}
+ */
+Gerrit.PatchRange;
+
+/**
+ * @typedef {{
+ *   changeNum: (string|number),
+ *   endpoint: string,
+ *   patchNum: (string|number|null|undefined),
+ *   errFn: (function(?Response, string=)|null|undefined),
+ *   params: (Object|null|undefined),
+ *   fetchOptions: (Object|null|undefined),
+ *   anonymizedEndpoint: (string|undefined),
+ *   reportEndpointAsIs: (boolean|undefined),
+ * }}
+ */
+Gerrit.ChangeFetchRequest;
+
+/**
+ * Object to describe a request for passing into _send.
+ * - method is the HTTP method to use in the request.
+ * - url is the URL for the request
+ * - body is a request payload.
+ *     TODO (beckysiegel) remove need for number at least.
+ * - 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.
+ * - contentType is the content type of the body.
+ * - headers is a key-value hash to describe HTTP headers for the request.
+ * - parseResponse states whether the result should be parsed as a JSON
+ *     object using getResponseObject.
+ *
+ * @typedef {{
+ *   method: string,
+ *   url: string,
+ *   body: (string|number|Object|null|undefined),
+ *   errFn: (function(?Response, string=)|null|undefined),
+ *   contentType: (string|null|undefined),
+ *   headers: (Object|undefined),
+ *   parseResponse: (boolean|undefined),
+ *   anonymizedUrl: (string|undefined),
+ *   reportUrlAsIs: (boolean|undefined),
+ * }}
+ */
+Gerrit.SendRequest;
+
+/**
+ * @typedef {{
+ *   changeNum: (string|number),
+ *   method: string,
+ *   patchNum: (string|number|undefined),
+ *   endpoint: string,
+ *   body: (string|number|Object|null|undefined),
+ *   errFn: (function(?Response, string=)|null|undefined),
+ *   contentType: (string|null|undefined),
+ *   headers: (Object|undefined),
+ *   parseResponse: (boolean|undefined),
+ *   anonymizedEndpoint: (string|undefined),
+ *   reportEndpointAsIs: (boolean|undefined),
+ * }}
+ */
+Gerrit.ChangeSendRequest;
+
+/**
+ * @typedef {{
+ *    url: string,
+ *    fetchOptions: (Object|null|undefined),
+ *    anonymizedUrl: (string|undefined),
+ * }}
+ */
+Gerrit.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),
+ * }}
+ */
+Gerrit.FetchJSONRequest;
+
+/**
+ * @typedef {{
+ *    message: string,
+ *    icon: string,
+ *    class: string,
+ *  }}
+ */
+Gerrit.PushCertificateValidation;
+
+/**
+ * Object containing layout values to be used in rendering size-bars.
+ * `max{Inserted,Deleted}` represent the largest values of the
+ * `lines_inserted` and `lines_deleted` fields of the files respectively. The
+ * `max{Addition,Deletion}Width` represent the width of the graphic allocated
+ * to the insertion or deletion side respectively. Finally, the
+ * `deletionOffset` value represents the x-position for the deletion bar.
+ *
+ * @typedef {{
+ *    maxInserted: number,
+ *    maxDeleted: number,
+ *    maxAdditionWidth: number,
+ *    maxDeletionWidth: number,
+ *    deletionOffset: number,
+ * }}
+ */
+Gerrit.LayoutStats;
+
+/**
+ * @typedef {{
+ *    changeNum: number,
+ *    path: string,
+ *    patchRange: !Gerrit.PatchRange,
+ *    projectConfig: (Object|undefined),
+ * }}
+ */
+Gerrit.CommentMeta;
+
+/**
+ * @typedef {{
+ *    meta: !Gerrit.CommentMeta,
+ *    left: !Array,
+ *    right: !Array,
+ * }}
+ */
+Gerrit.CommentsBySide;
+
+/**
+ * The DiffIntralineInfo entity contains information about intraline edits in a
+ * file.
+ *
+ * The information consists of a list of <skip length, mark length> pairs, where
+ * the skip length is the number of characters between the end of the previous
+ * edit and the start of this edit, and the mark length is the number of edited
+ * characters following the skip. The start of the edits is from the beginning
+ * of the related diff content lines.
+ *
+ * Note that the implied newline character at the end of each line is included
+ * in the length calculation, and thus it is possible for the edits to span
+ * newlines.
+ *
+ * @typedef {!Array<number>}
+ */
+Gerrit.IntralineInfo;
+
+/**
+ * A portion of the diff that is treated the same.
+ *
+ * Called `DiffContent` in the API, see
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ *
+ * @typedef {{
+ *  ab: ?Array<!string>,
+ *  a: ?Array<!string>,
+ *  b: ?Array<!string>,
+ *  skip: ?number,
+ *  edit_a: ?Array<!Gerrit.IntralineInfo>,
+ *  edit_b: ?Array<!Gerrit.IntralineInfo>,
+ *  due_to_rebase: ?boolean,
+ *  common: ?boolean
+ * }}
+ */
+Gerrit.DiffChunk;
+
+/**
+ * Special line number which should not be collapsed into a shared region.
+ *
+ * @typedef {{
+ *  number: number,
+ *  leftSide: boolean
+ * }}
+ */
+Gerrit.LineOfInterest;
+
+/**
+ * @typedef {{
+ *    html: Node,
+ *    position: number,
+ *    length: number,
+ * }}
+ */
+Gerrit.CommentLinkItem;
+
+/**
+ * @typedef {{
+ *   name: string,
+ *   value: Object,
+ * }}
+ */
+Gerrit.GrSuggestionItem;
+
+/**
+ * @typedef {{
+ *    getSuggestions: function(string): Promise<Array<Object>>,
+ *    makeSuggestionItem: function(Object): Gerrit.GrSuggestionItem,
+ * }}
+ */
+Gerrit.GrSuggestionsProvider;
\ No newline at end of file
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index dd16ba7..f1b4666 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -14,8 +14,7 @@
 
 if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
     CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
-    # TODO(paladox): Fix Firefox support for headless mode
-    FIREFOX_OPTIONS=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2f5df90..1a2d299 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -38,11 +38,12 @@
 )
 
 var (
-	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port       = flag.String("port", ":8081", "Port to serve HTTP requests on")
-	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme     = flag.String("scheme", "https", "URL scheme")
-	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
+	plugins               = flag.String("plugins", "", "comma seperated plugin paths to serve")
+	port                  = flag.String("port", ":8081", "Port to serve HTTP requests on")
+	host                  = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	scheme                = flag.String("scheme", "https", "URL scheme")
+	cdnPattern            = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
+	bundledPluginsPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_assets/[0-9.]*")
 )
 
 func main() {
@@ -74,6 +75,7 @@
 	http.HandleFunc("/accounts/", handleProxy)
 	http.HandleFunc("/config/", handleProxy)
 	http.HandleFunc("/projects/", handleProxy)
+	http.HandleFunc("/static/", handleProxy)
 	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
 
 	if len(*plugins) > 0 {
@@ -102,7 +104,8 @@
 func handleIndex(writer http.ResponseWriter, originalRequest *http.Request) {
 	fakeRequest := &http.Request{
 		URL: &url.URL{
-			Path: "/",
+			Path:     "/",
+			RawQuery: originalRequest.URL.RawQuery,
 		},
 	}
 	handleProxy(writer, fakeRequest)
@@ -168,7 +171,7 @@
 func patchResponse(req *http.Request, res *http.Response) io.Reader {
 	switch req.URL.EscapedPath() {
 	case "/":
-		return replaceCdn(res.Body)
+		return rewriteHostPage(res.Body)
 	case "/config/server/info":
 		return injectLocalPlugins(res.Body)
 	default:
@@ -176,13 +179,42 @@
 	}
 }
 
-func replaceCdn(reader io.Reader) io.Reader {
+func rewriteHostPage(reader io.Reader) io.Reader {
 	buf := new(bytes.Buffer)
 	buf.ReadFrom(reader)
 	original := buf.String()
 
+	// Simply remove all CDN references, so files are loaded from the local file system  or the proxy
+	// server instead.
 	replaced := cdnPattern.ReplaceAllString(original, "")
 
+	// Modify window.INITIAL_DATA so that it has the same effect as injectLocalPlugins. To achieve
+	// this let's add JavaScript lines at the end of the <script>...</script> snippet that also
+	// contains window.INITIAL_DATA=...
+	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
+	if len(*plugins) > 0 {
+		// If the host page contains a reference to a plugin bundle that would be preloaded, then remove it.
+		replaced = bundledPluginsPattern.ReplaceAllString(replaced, "")
+
+		insertionPoint := strings.Index(replaced, "</script>")
+		builder := new(strings.Builder)
+		builder.WriteString(
+			"window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths = []; ")
+		builder.WriteString(
+			"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths = []; ")
+		for _, p := range strings.Split(*plugins, ",") {
+			if filepath.Ext(p) == ".html" {
+				builder.WriteString(
+					"window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths.push('" + p + "'); ")
+			}
+			if filepath.Ext(p) == ".js" {
+				builder.WriteString(
+					"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths.push('" + p + "'); ")
+			}
+		}
+		replaced = replaced[:insertionPoint] + builder.String() + replaced[insertionPoint:]
+	}
+
 	return strings.NewReader(replaced)
 }
 
@@ -207,11 +239,11 @@
 	jsResources := getJsonPropByPath(response, jsPluginsPath).([]interface{})
 
 	for _, p := range strings.Split(*plugins, ",") {
-		if strings.HasSuffix(p, ".html") {
+		if filepath.Ext(p) == ".html" {
 			htmlResources = append(htmlResources, p)
 		}
 
-		if strings.HasSuffix(p, ".js") {
+		if filepath.Ext(p) == ".js" {
 			jsResources = append(jsResources, p)
 		}
 	}
@@ -261,7 +293,7 @@
 
 // Any path prefixes that should resolve to index.html.
 var (
-	fePaths    = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/"}
+	fePaths    = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
 	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
 )
 
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
new file mode 100644
index 0000000..f4ebe90
--- /dev/null
+++ b/prologtests/examples/BUILD
@@ -0,0 +1,15 @@
+package(default_visibility = ["//visibility:public"])
+
+DUMMY = ["dummy.sh"]
+
+# Enable prologtests on newer Java versions again, when this Bazel bug is fixed:
+# https://github.com/bazelbuild/bazel/issues/9391
+sh_test(
+    name = "test_examples",
+    srcs = select({
+        "//:java11": DUMMY,
+        "//:java_next": DUMMY,
+        "//conditions:default": ["run.sh"],
+    }),
+    data = glob(["*.pl"]) + ["//:gerrit.war"],
+)
diff --git a/prologtests/examples/README.md b/prologtests/examples/README.md
new file mode 100644
index 0000000..12eb256e
--- /dev/null
+++ b/prologtests/examples/README.md
@@ -0,0 +1,54 @@
+# Prolog Unit Test Examples
+
+## Run all examples
+
+Build a local gerrit.war and then run the script:
+
+    ./run.sh
+
+Note that a local Gerrit server is not needed because
+these unit test examples redefine wrappers of the `gerrit:change\*`
+rules to provide mocked change data.
+
+## Add a new unit test
+
+Please follow the pattern in `t1.pl`, `t2.pl`, or `t3.pl`.
+
+* Put code to be tested in a file, e.g. `rules.pl`.
+  For easy unit testing, split long clauses into short ones
+  and test every positive and negative path.
+
+* Create a new unit test file, e.g. `t1.pl`,
+  which should _load_ the test source file and `utils.pl`.
+
+      % First load all source files and the utils.pl.
+      :- load([aosp_rules,utils]).
+
+      :- begin_tests(t1).  % give this test any name
+
+      % Use test0/1 or test1/1 to verify failed/passed goals.
+
+      :- end_tests(_,0).   % check total pass/fail counts
+
+* Optionally replace calls to gerrit functions that depend on repository.
+  For example, define the following wrappers and in source code, use
+  `change_branch/1` instead of `gerrti:change_branch/1`.
+
+      change_branch(X) :- gerrit:change_branch(X).
+      commit_label(L,U) :- gerrit:commit_label(L,U).
+
+* In unit test file, redefine the gerrit function wrappers and test.
+  For example, in `t3.pl`, we have:
+
+      :- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+      :- test1(uploader(user(42))).
+      :- test0(is_exempt_uploader).
+
+      % is_exempt_uploader/0 is expected to fail because it is
+      % is_exempt_uploader :- uploader(user(Id)), memberchk(Id, [104, 106]).
+
+      % Note that gerrit:remove_label does not depend on Gerrit repository,
+      % so its caller remove_label/1 is tested without any redefinition.
+
+      :- test1(remove_label('MyReview',[],[])).
+      :- test1(remove_label('MyReview',submit(),submit())).
diff --git a/prologtests/examples/aosp_rules.pl b/prologtests/examples/aosp_rules.pl
new file mode 100644
index 0000000..18e8a73
--- /dev/null
+++ b/prologtests/examples/aosp_rules.pl
@@ -0,0 +1,148 @@
+% A simplified and mocked AOSP rules.pl
+
+%%%%% wrapper functions for unit tests
+
+change_branch(X) :- gerrit:change_branch(X).
+change_project(X) :- gerrit:change_project(X).
+commit_author(U,N,M) :- gerrit:commit_author(U,N,M).
+commit_delta(X) :- gerrit:commit_delta(X).
+commit_label(L,U) :- gerrit:commit_label(L,U).
+uploader(X) :- gerrit:uploader(X).
+
+%%%%% true/false conditions
+
+% Special auto-merger accounts.
+is_exempt_uploader :-
+  uploader(user(Id)),
+  memberchk(Id, [104, 106]).
+
+% Build cop overrides everything.
+has_build_cop_override :-
+  commit_label(label('Build-Cop-Override', 1), _).
+
+is_exempt_from_reviews :-
+  or(is_exempt_uploader, has_build_cop_override).
+
+% Some files in selected projects need API review.
+needs_api_review :-
+  commit_delta('^(.*/)?api/|^(system-api/)'),
+  change_project(Project),
+  memberchk(Project, [
+    'platform/external/apache-http',
+    'platform/frameworks/base',
+    'platform/frameworks/support',
+    'platform/packages/services/Car',
+    'platform/prebuilts/sdk'
+  ]).
+
+% Some branches need DrNo review.
+needs_drno_review :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-alpha-dev',
+    'refs/heads/my-beta-dev'
+  ]).
+
+% Some author email addresses need Qualcomm-Review.
+needs_qualcomm_review :-
+  commit_author(_, _, M),
+  regex_matches(
+'.*@(qti.qualcomm.com|qca.qualcomm.com|quicinc.com|qualcomm.com)', M).
+
+% Special projects, branches, user accounts
+% can opt out owners review.
+opt_out_find_owners :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-beta-testing',
+    'refs/heads/my-testing'
+  ]).
+
+% Special projects, branches, user accounts
+% can opt in owners review.
+% Note that opt_out overrides opt_in.
+opt_in_find_owners :- true.
+
+
+%%%%% Simple list filters.
+
+remove_label(X, In, Out) :-
+  gerrit:remove_label(In, label(X, _), Out).
+
+% Slow but simple for short input list.
+remove_review_categories(In, Out) :-
+  remove_label('API-Review', In, L1),
+  remove_label('Code-Review', L1, L2),
+  remove_label('DrNo-Review', L2, L3),
+  remove_label('Owner-Review-Vote', L3, L4),
+  remove_label('Qualcomm-Review', L4, L5),
+  remove_label('Verified', L5, Out).
+
+
+%%%%% Missing rules in Gerrit Prolog Cafe.
+
+or(InA, InB) :- once((A;B)).
+
+not(Goal) :- Goal -> false ; true.
+
+% memberchk(+Element, +List)
+memberchk(X, [H|T]) :-
+  (X = H -> true ; memberchk(X, T)).
+
+maplist(Functor, In, Out) :-
+  (In = []
+  -> Out = []
+  ;  (In = [X1|T1],
+      Out = [X2|T2],
+      Goal =.. [Functor, X1, X2],
+      once(Goal),
+      maplist(Functor, T1, T2)
+     )
+  ).
+
+
+%%%%% Conditional rules and filters.
+
+submit_filter(In, Out) :-
+  (is_exempt_from_reviews
+  -> remove_review_categories(In, Out)
+  ;  (check_review(needs_api_review,
+          'API_Review', In, L1),
+      check_review(needs_drno_review,
+          'DrNo-Review', L1, L2),
+      check_review(needs_qualcomm_review,
+          'Qualcomm-Review', L2, L3),
+      check_find_owners(L3, Out)
+     )
+  ).
+
+check_review(NeedReview, Label, In, Out) :-
+  (NeedReview
+  -> Out = In
+  ;  remove_label(Label, In, Out)
+  ).
+
+% If opt_out_find_owners is true,
+% remove all 'Owner-Review-Vote' label;
+% else if opt_in_find_owners is true,
+%      call find_owners:submit_filter;
+% else default to no find_owners filter.
+check_find_owners(In, Out) :-
+  (opt_out_find_owners
+  -> remove_label('Owner-Review-Vote', In, Temp)
+  ; (opt_in_find_owners
+    -> find_owners:submit_filter(In, Temp)
+    ; In = Temp
+    )
+  ),
+  Temp =.. [submit | L1],
+  remove_label('Owner-Approved', L1, L2),
+  maplist(owner_may_to_need, L2, L3),
+  Out =.. [submit | L3].
+
+% change may(_) to need(_) to block submit.
+owner_may_to_need(In, Out) :-
+  (In = label('Owner-Review-Vote', may(_))
+  -> Out = label('Owner-Review-Vote', need(_))
+  ;  Out = In
+  ).
diff --git a/prologtests/examples/dummy.sh b/prologtests/examples/dummy.sh
new file mode 100755
index 0000000..2e0cca3
--- /dev/null
+++ b/prologtests/examples/dummy.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+# Skip all prolog tests for newer Java versions.
+# See https://github.com/bazelbuild/bazel/issues/9391
+# for more details why we cannot support running tests
+# on newer Java versions for now.
diff --git a/prologtests/examples/load.pl b/prologtests/examples/load.pl
new file mode 100644
index 0000000..f5b49e8
--- /dev/null
+++ b/prologtests/examples/load.pl
@@ -0,0 +1,26 @@
+% If you have 1.4.3 or older Prolog-Cafe, you need to
+% use (consult(load), load(load)) to get definition of load.
+% Then use load([f1,f2,...]) to load multiple source files.
+
+% Input is a list of file names or a single file name.
+% Use a conditional expression style without cut operator.
+load(X) :-
+  ( (X = [])
+  -> true
+  ; ( (X = [H|T])
+    -> (load_file(H), load(T))
+    ;  load_file(X)
+    )
+  ).
+
+% load_file is '$consult' without the bug of unbound 'File' variable.
+% For repeated unit tests, skip statistics and print_message.
+load_file(F) :- atom(F), !,
+  '$prolog_file_name'(F, PF),
+  open(PF, read, In),
+  % print_message(info, [loading,PF,'...']),
+  % statistics(runtime, _),
+  consult_stream(PF, In),
+  % statistics(runtime, [_,T]),
+  % print_message(info, [PF,'loaded in',T,msec]),
+  close(In).
diff --git a/prologtests/examples/rules.pl b/prologtests/examples/rules.pl
new file mode 100644
index 0000000..1a7b17c
--- /dev/null
+++ b/prologtests/examples/rules.pl
@@ -0,0 +1,29 @@
+% An example source file to be tested.
+
+% Add common rules missing in Prolog Cafe.
+memberchk(X, [H|T]) :-
+  (X = H) -> true ; memberchk(X, T).
+
+% A rule that can succeed/backtrack multiple times.
+super_users(1001).
+super_users(1002).
+
+% Deterministic rule that pass/fail only once.
+is_super_user(X) :- memberchk(X, [1001, 1002]).
+
+% Another rule that can pass 5 times.
+multi_users(101).
+multi_users(102).
+multi_users(103).
+multi_users(104).
+multi_users(105).
+
+% Okay, single deterministic fact.
+single_user(abc).
+
+% Wrap calls to gerrit repository, to be redefined in tests.
+change_owner(X) :- gerrit:change_owner(X).
+
+% To test is_owner without gerrit:change_owner,
+% we should redefine change_owner.
+is_owner(X) :- change_owner(X).
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
new file mode 100755
index 0000000..947c153
--- /dev/null
+++ b/prologtests/examples/run.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+TESTS="t1 t2 t3"
+
+# Note that both t1.pl and t2.pl test code in rules.pl.
+# Unit tests are usually longer than the tested code.
+# So it is common to test one source file with multiple
+# unit test files.
+
+LF=$'\n'
+PASS=""
+FAIL=""
+
+echo "#### TEST_SRCDIR = ${TEST_SRCDIR}"
+
+if [ "${TEST_SRCDIR}" == "" ]; then
+  # Assume running alone
+  GERRIT_WAR="../../bazel-bin/gerrit.war"
+  SRCDIR="."
+else
+  # Assume running from bazel
+  GERRIT_WAR=`pwd`/gerrit.war
+  SRCDIR="prologtests/examples"
+fi
+
+# Default GERRIT_TMP is ~/.gerritcodereview/tmp,
+# which won't be writable in a bazel test sandbox.
+/bin/mkdir -p /tmp/gerrit
+export GERRIT_TMP=/tmp/gerrit
+
+for T in $TESTS
+do
+
+  pushd $SRCDIR
+
+  # Unit tests do not need to define clauses in packages.
+  # Use one prolog-shell per unit test, to avoid name collision.
+  echo "### Running test ${T}.pl"
+  echo "[$T]." | java -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
+
+  if [ "x$?" != "x0" ]; then
+    echo "### Test ${T}.pl failed."
+    FAIL="${FAIL}${LF}FAIL: Test ${T}.pl"
+  else
+    PASS="${PASS}${LF}PASS: Test ${T}.pl"
+  fi
+
+  popd
+
+  # java -jar ../../bazel-bin/gerrit.war prolog-shell -s $T < /dev/null
+  # Calling prolog-shell with -s flag works for small files,
+  # but got run-time exception with t3.pl.
+  #   com.googlecode.prolog_cafe.exceptions.ReductionLimitException:
+  #   exceeded reduction limit of 1048576
+done
+
+echo "$PASS"
+
+if [ "$FAIL" != "" ]; then
+  echo "$FAIL"
+  exit 1
+fi
diff --git a/prologtests/examples/t1.pl b/prologtests/examples/t1.pl
new file mode 100644
index 0000000..caf9061
--- /dev/null
+++ b/prologtests/examples/t1.pl
@@ -0,0 +1,20 @@
+:- load([rules,utils]).
+:- begin_tests(t1).
+
+:- test1(true).     % expect true to pass
+:- test0(false).    % expect false to fail
+
+:- test1(X = 3).    % unification should pass
+:- test1(_ = 3).    % unification should pass
+:- test0(X \= 3).   % not-unified should fail
+
+% (7-4) should have expected result
+:- test1((X is (7-4), X =:= 3)).
+:- test1((X is (7-4), X =\= 4)).
+
+% memberchk should pass/fail exactly once
+:- test1(memberchk(3,[1,3,5,3])).
+:- test0(memberchk(2,[1,3,5,3])).
+:- test0(memberchk(2,[])).
+
+:- end_tests_or_halt(0).  % expect no failure
diff --git a/prologtests/examples/t2.pl b/prologtests/examples/t2.pl
new file mode 100644
index 0000000..9424b53
--- /dev/null
+++ b/prologtests/examples/t2.pl
@@ -0,0 +1,25 @@
+:- load([rules,utils]).
+:- begin_tests(t2).
+
+% expected to pass or fail once.
+:- test0(super_users(1000)).
+:- test1(super_users(1001)).
+
+:- test1(is_super_user(1001)).
+:- test1(is_super_user(1002)).
+:- test0(is_super_user(1003)).
+
+:- test1(super_users(X)).  % expected fail (pass twice)
+:- test1(multi_users(X)).  % expected fail (pass many times)
+
+:- test1(single_user(X)).  % expected pass once
+
+% Redefine change_owner, skip gerrit:change_owner,
+% then test is_owner without a gerrit repository.
+
+:- redefine(change_owner,1,(change_owner(42))).
+:- test1(is_owner(42)).
+:- test1(is_owner(X)).
+:- test0(is_owner(24)).
+
+:- end_tests_or_halt(2).  % expect 2 failures
diff --git a/prologtests/examples/t3.pl b/prologtests/examples/t3.pl
new file mode 100644
index 0000000..02badc0
--- /dev/null
+++ b/prologtests/examples/t3.pl
@@ -0,0 +1,69 @@
+:- load([aosp_rules,utils]).
+
+:- begin_tests(t3_basic_conditions).
+
+%% A negative test of is_exempt_uploader.
+:- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+:- test1(uploader(user(42))).
+:- test0(is_exempt_uploader).
+
+%% Helper functions for positive test of is_exempt_uploader.
+test_is_exempt_uploader(List) :- maplist(test1_uploader, List, _).
+test1_uploader(X,_) :-
+  redefine(uploader,1,uploader(user(X))),
+  test1(uploader(user(X))),
+  test1(is_exempt_uploader).
+:- test_is_exempt_uploader([104, 106]).
+
+%% Test has_build_cop_override.
+:- redefine(commit_label,2,commit_label(label('Code-Review',1),user(102))).
+:- test0(has_build_cop_override).
+commit_label(label('Build-Cop-Override',1),user(101)).  % mocked 2nd label
+:- test1(has_build_cop_override).
+:- test1(commit_label(label(_,_),_)).           % expect fail, two matches
+:- test1(commit_label(label('Build-Cop-Override',_),_)).  % good, one pass
+
+%% TODO: more test for is_exempt_from_reviews.
+
+%% Test needs_api_review, which checks commit_delta and project.
+% Helper functions:
+test_needs_api_review(File, Project, Tester) :-
+  redefine(commit_delta,1,(commit_delta(R) :- regex_matches(R, File))),
+  redefine(change_project,1,change_project(Project)),
+  Goal =.. [Tester, needs_api_review],
+  msg('# check CL with changed file ', File, ' in ', Project),
+  once((Goal ; true)).  % do not backtrack
+
+:- test_needs_api_review('apio/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('d1/d2/api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('system-api/d/t.c', 'platform/external/apache-http', test1).
+
+%% TODO: Test needs_drno_review, needs_qualcomm_review
+
+%% TODO: Test opt_out_find_owners.
+
+:- test1(opt_in_find_owners).  % default, unless opt_out_find_owners
+
+:- end_tests_or_halt(1).  % expect 1 failure of multiple commit_label
+
+%% Test remove_label
+:- begin_tests(t3_remove_label).
+
+:- test1(remove_label('MyReview',[],[])).
+:- test1(remove_label('MyReview',submit(),submit())).
+:- test1(remove_label(myR,[label(a,X)],[label(a,X)])).
+:- test1(remove_label(myR,[label(myR,_)],[])).
+:- test1(remove_label(myR,[label(a,X),label(myR,_)],[label(a,X)])).
+:- test1(remove_label(myR,submit(label(a,X)),submit(label(a,X)))).
+:- test1(remove_label(myR,submit(label(myR,_)),submit())).
+
+%% Test maplist
+double(X,Y) :- Y is X * X.
+:- test1(maplist(double, [2,4,6], [4,16,36])).
+:- test1(maplist(double, [], [])).
+
+:- end_tests_or_halt(0).  % expect no failure
+
+%% TODO: Add more tests.
diff --git a/prologtests/examples/utils.pl b/prologtests/examples/utils.pl
new file mode 100644
index 0000000..8d15067
--- /dev/null
+++ b/prologtests/examples/utils.pl
@@ -0,0 +1,78 @@
+%% Unit test helpers
+
+% Write one line message.
+msg(A) :- write(A), nl.
+msg(A,B) :- write(A), msg(B).
+msg(A,B,C) :- write(A), msg(B,C).
+msg(A,B,C,D) :- write(A), msg(B,C,D).
+msg(A,B,C,D,E) :- write(A), msg(B,C,D,E).
+msg(A,B,C,D,E,F) :- write(A), msg(B,C,D,E,F).
+
+% Redefine a caluse.
+redefine(Atom,Arity,Clause) :- abolish(Atom/Arity), assertz(Clause).
+
+% Increment/decrement of pass/fail counters.
+set_counters(N,X,Y) :- redefine(test_count,3,test_count(N,X,Y)).
+get_counters(N,X,Y) :- clause(test_count(N,X,Y), _) -> true ; (X=0, Y=0).
+inc_pass_count :- get_counters(N,P,F), P1 is P + 1, set_counters(N,P1,F).
+inc_fail_count :- get_counters(N,P,F), F1 is F + 1, set_counters(N,P,F1).
+
+% Report pass or fail of G.
+pass_1(G) :- msg('PASS: ', G), inc_pass_count.
+fail_1(G) :- msg('FAIL: ', G), inc_fail_count.
+
+% Report pass or fail of not(G).
+pass_0(G) :- msg('PASS: not(', G, ')'), inc_pass_count.
+fail_0(G) :- msg('FAIL: not(', G, ')'), inc_fail_count.
+
+% Report a test as failed if it passed 2 or more times
+pass_twice(G) :-
+  msg('FAIL: (pass twice): ', G),
+  inc_fail_count.
+pass_many(G) :-
+  G = [A,B|_],
+  length(G, N),
+  msg('FAIL: (pass ', N, ' times): ', [A,B,'...']),
+  inc_fail_count.
+
+% Test if G fails.
+test0(G) :- once(G) -> fail_0(G) ; pass_0(G).
+
+% Test if G passes exactly once.
+test1(G) :-
+  findall(G, G, S), length(S, N),
+  (N == 0
+   -> fail_1(G)
+   ;  (N == 1
+       -> pass_1(S)
+       ;  (N == 2 -> pass_twice(S) ; pass_many(S))
+      )
+  ).
+
+% Report the begin of test N.
+begin_tests(N) :-
+  nl,
+  msg('BEGIN test ',N),
+  set_counters(N,0,0).
+
+% Repot the end of test N and total pass/fail counts,
+% and check if the numbers are as exected OutP/OutF.
+end_tests(OutP,OutF) :-
+  get_counters(N,P,F),
+  (OutP = P
+   -> msg('Expected #PASS: ', OutP)
+   ;  (msg('ERROR: expected #PASS is ',OutP), !, fail)
+  ),
+  (OutF = F
+   -> msg('Expected #FAIL: ', OutF)
+   ;  (msg('ERROR: expected #FAIL is ',OutF), !, fail)
+  ),
+  msg('END test ', N),
+  nl.
+
+% Repot the end of test N and total pass/fail counts.
+end_tests(N) :- end_tests(N,_,_).
+
+% Call end_tests/2 and halt if the fail count is unexpected.
+end_tests_or_halt(ExpectedFails) :-
+  end_tests(_,ExpectedFails); (flush_output, halt(1)).
diff --git a/proto/cache.proto b/proto/cache.proto
index b34dbf3..10e0216 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -75,7 +75,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 19
+// Next ID: 23
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -110,8 +110,8 @@
     string submission_id = 13;
     bool has_submission_id = 14;
 
-    int32 assignee = 15;
-    bool has_assignee = 16;
+    reserved 15;  // assignee
+    reserved 16;  // has_assignee
 
     string status = 17;
     bool has_status = 18;
@@ -130,7 +130,7 @@
   // which case attempting to use the ChangeNotesCache is programmer error.
   ChangeColumnsProto columns = 3;
 
-  repeated int32 past_assignee = 4;
+  reserved  4; // past_assignee
 
   repeated string hashtag = 5;
 
@@ -178,11 +178,25 @@
   // Raw ChangeMessage proto as produced by ChangeMessageProtoConverter.
   repeated bytes change_message = 15;
 
-  // JSON produced from com.google.gerrit.reviewdb.client.Comment.
+  // JSON produced from com.google.gerrit.entities.Comment.
   repeated string published_comment = 16;
 
   reserved 17;  // read_only_until
   reserved 18;  // has_read_only_until
+
+  // Number of updates to the change's meta ref.
+  int32 update_count = 19;
+
+  string server_id = 20;
+  bool has_server_id = 21;
+
+  message AssigneeStatusUpdateProto {
+    int64 date = 1;
+    int32 updated_by = 2;
+    int32 current_assignee = 3;
+    bool has_current_assignee = 4;
+  }
+  repeated AssigneeStatusUpdateProto assignee_update = 22;
 }
 
 
diff --git a/proto/entities.proto b/proto/entities.proto
index d2851d3..374b47c 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -18,19 +18,19 @@
 
 option java_package = "com.google.gerrit.proto";
 
-// Serialized form of com.google.gerrit.reviewdb.client.Change.Id.
+// Serialized form of com.google.gerrit.entities.Change.Id.
 // Next ID: 2
 message Change_Id {
   required int32 id = 1;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.Change.Key.
+// Serialized form of com.google.gerrit.entities.Change.Key.
 // Next ID: 2
 message Change_Key {
   optional string id = 1;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.Change.
+// Serialized form of com.google.gerrit.entities.Change.
 // Next ID: 24
 message Change {
   required Change_Id change_id = 1;
@@ -61,14 +61,14 @@
   reserved 101;  // note_db_state
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.ChangeMessage.
+// Serialized form of com.google.gerrit.enities.ChangeMessage.
 // Next ID: 3
 message ChangeMessage_Key {
   required Change_Id change_id = 1;
   required string uuid = 2;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.ChangeMessage.
+// Serialized form of com.google.gerrit.entities.ChangeMessage.
 // Next ID: 8
 message ChangeMessage {
   required ChangeMessage_Key key = 1;
@@ -80,18 +80,18 @@
   optional Account_Id real_author = 7;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.PatchSet.Id.
+// Serialized form of com.google.gerrit.entities.PatchSet.Id.
 // Next ID: 3
 message PatchSet_Id {
   required Change_Id change_id = 1;
-  required int32 patch_set_id = 2;
+  required int32 id = 2;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.PatchSet.
+// Serialized form of com.google.gerrit.entities.PatchSet.
 // Next ID: 10
 message PatchSet {
   required PatchSet_Id id = 1;
-  optional RevId revision = 2;
+  optional ObjectId commitId = 2;
   optional Account_Id uploader_account_id = 3;
   optional fixed64 created_on = 4;
   optional string groups = 6;
@@ -103,27 +103,27 @@
   reserved 7;  // pushCertficate
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.Account.Id.
+// Serialized form of com.google.gerrit.entities.Account.Id.
 // Next ID: 2
 message Account_Id {
   required int32 id = 1;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.LabelId.
+// Serialized form of com.google.gerrit.entities.LabelId.
 // Next ID: 2
 message LabelId {
   required string id = 1;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.PatchSetApproval.Key.
+// Serialized form of com.google.gerrit.entities.PatchSetApproval.Key.
 // Next ID: 4
 message PatchSetApproval_Key {
   required PatchSet_Id patch_set_id = 1;
   required Account_Id account_id = 2;
-  required LabelId category_id = 3;
+  required LabelId label_id = 3;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.PatchSetApproval.
+// Serialized form of com.google.gerrit.entities.PatchSetApproval.
 // Next ID: 9
 message PatchSetApproval {
   required PatchSetApproval_Key key = 1;
@@ -138,21 +138,22 @@
   reserved 5;  // changeSortKey
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.Project.NameKey.
+// Serialized form of com.google.gerrit.entities.Project.NameKey.
 // Next ID: 2
 message Project_NameKey {
   optional string name = 1;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.Branch.NameKey.
+// Serialized form of com.google.gerrit.entities.Branch.NameKey.
 // Next ID: 3
 message Branch_NameKey {
-  optional Project_NameKey project_name = 1;
-  optional string branch_name = 2;
+  optional Project_NameKey project = 1;
+  optional string branch = 2;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.RevId.
+// Serialized form of org.eclipse.jgit.lib.ObjectId.
 // Next ID: 2
-message RevId {
-  optional string id = 1;
+message ObjectId {
+  // Hex string representation of the ID.
+  optional string name = 1;
 }
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 8f151a8..3feb1b4 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,11 +19,14 @@
 {template .Index}
   {@param canonicalPath: ?}
   {@param staticResourcePath: ?}
+  {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
   {@param? assetsPath: ?}  /** {string} URL to static assets root, if served from CDN. */
   {@param? assetsBundle: ?}  /** {string} Assets bundle .html file, served from $assetsPath. */
   {@param? faviconPath: ?}
   {@param? versionInfo: ?}
-  {@param? polymer2: ?}
+  {@param? polyfillCE: ?}
+  {@param? polyfillSD: ?}
+  {@param? polyfillSC: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
@@ -36,12 +39,27 @@
   </noscript>
 
   <script>
+    // Disable extra font load from paper-styles
+    window.polymerSkipLoadingFontRoboto = true;
     window.CLOSURE_NO_DEPS = true;
     {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
     {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
-    {if $polymer2}window.POLYMER2 = true;{/if}
+    {if $polyfillCE}if (window.customElements) window.customElements.forcePolyfill = true;{/if}
+    {if $polyfillSD}{literal}ShadyDOM = { force: true };{/literal}{/if}
+    {if $polyfillSC}{literal}ShadyCSS = { shimcssproperties: true};{/literal}{/if}
+    {if $gerritInitialData}
+      // INITIAL_DATA is a string that represents a JSON map. It's inlined here so that we can
+      // spare calls to the API when starting up the app.
+      // The map maps from endpoint to returned value. This matches Gerrit's REST API 1:1, so the
+      // values here can be used as a drop-in replacement for calls to the API.
+      //
+      // Example:
+      // '/config/server/version' => '3.0.0-468-g0757b52a7d'
+      // '/accounts/self/detail' => { 'username' : 'gerrit-user' }
+      window.INITIAL_DATA = JSON.parse({$gerritInitialData});
+    {/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -60,7 +78,9 @@
   <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
+
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+
   // Content between webcomponents-lite and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index d92ec51..eba7e4b 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -346,7 +346,7 @@
 test -z "$GERRIT_USER" && GERRIT_USER=`whoami`
 RUN_ARGS="-jar $GERRIT_WAR daemon -d $GERRIT_SITE"
 if test "`get_config --bool container.slave`" = "true" ; then
-  RUN_ARGS="$RUN_ARGS --slave --enable-httpd --headless"
+  RUN_ARGS="$RUN_ARGS --replica --enable-httpd --headless"
 fi
 DAEMON_OPTS=`get_config --get-all container.daemonOpt`
 if test -n "$DAEMON_OPTS" ; then
diff --git a/resources/com/google/gerrit/server/mail/Abandoned.soy b/resources/com/google/gerrit/server/mail/Abandoned.soy
index 2785ffc..d5aac0e 100644
--- a/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -17,7 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * .Abandoned template will determine the contents of the email related to a
+ * The .Abandoned template will determine the contents of the email related to a
  * change being abandoned.
  */
 {template .Abandoned kind="text"}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index e997776..e88c424 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -62,3 +62,8 @@
   This might be caused by an ongoing maintenance or a data corruption.
   {call .InboundEmailRejectionFooter /}
 {/template}
+
+{template .InboundEmailRejection_COMMENT_REJECTED kind="text"}
+  Gerrit Code Review rejected one or more comments because they did not pass validation.
+  {call .InboundEmailRejectionFooter /}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index f879270..e17508d 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -78,3 +78,10 @@
   <p>
   {call .InboundEmailRejectionFooterHtml /}
 {/template}
+
+{template .InboundEmailRejectionHtml_COMMENT_REJECTED}
+  <p>
+    Gerrit Code Review rejected one or more comments because they did not pass validation.
+  </p>
+  {call .InboundEmailRejectionFooterHtml /}
+{/template}
diff --git a/tools/BUILD b/tools/BUILD
index 9a53c8b..f5da450 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -21,72 +21,75 @@
 # enabled. This warnings list is originally based on:
 # https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
 # However, feel free to add any additional errors. Thus far they have all been pretty useful.
+# TODO(davido): Enable ImmutableAnnotationChecker again when these issues are fixed:
+# https://github.com/google/error-prone/issues/1348
+# https://github.com/bazelbuild/bazel/issues/9378
 java_package_configuration(
     name = "error_prone",
     javacopts = [
         "-XepDisableWarningsInGeneratedCode",
-        "-Xep:AmbiguousMethodReference:WARN",
+        "-Xep:AmbiguousMethodReference:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
-        "-Xep:BadAnnotationImplementation:WARN",
-        "-Xep:BadComparable:WARN",
+        "-Xep:BadAnnotationImplementation:ERROR",
+        "-Xep:BadComparable:ERROR",
         "-Xep:BoxedPrimitiveConstructor:ERROR",
-        "-Xep:CannotMockFinalClass:WARN",
+        "-Xep:CannotMockFinalClass:ERROR",
         "-Xep:ClassCanBeStatic:ERROR",
-        "-Xep:ClassNewInstance:WARN",
+        "-Xep:ClassNewInstance:ERROR",
         "-Xep:DateFormatConstant:ERROR",
         "-Xep:DefaultCharset:ERROR",
-        "-Xep:DoubleCheckedLocking:WARN",
-        "-Xep:ElementsCountedInLoop:WARN",
-        "-Xep:EqualsHashCode:WARN",
-        "-Xep:EqualsIncompatibleType:WARN",
+        "-Xep:DoubleCheckedLocking:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:EqualsHashCode:ERROR",
+        "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
-        "-Xep:Finally:WARN",
-        "-Xep:FloatingPointLiteralPrecision:WARN",
-        "-Xep:FragmentInjection:WARN",
-        "-Xep:FragmentNotInstantiable:WARN",
-        "-Xep:FunctionalInterfaceClash:WARN",
-        "-Xep:FutureReturnValueIgnored:WARN",
-        "-Xep:GetClassOnEnum:WARN",
-        "-Xep:ImmutableAnnotationChecker:WARN",
-        "-Xep:ImmutableEnumChecker:WARN",
-        "-Xep:IncompatibleModifiers:WARN",
-        "-Xep:InjectOnConstructorOfAbstractClass:WARN",
-        "-Xep:InputStreamSlowMultibyteRead:WARN",
-        "-Xep:IterableAndIterator:WARN",
-        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:WARN",
-        "-Xep:JUnitAmbiguousTestClass:WARN",
-        "-Xep:LiteralClassName:WARN",
+        "-Xep:Finally:ERROR",
+        "-Xep:FloatingPointLiteralPrecision:ERROR",
+        "-Xep:FragmentInjection:ERROR",
+        "-Xep:FragmentNotInstantiable:ERROR",
+        "-Xep:FunctionalInterfaceClash:ERROR",
+        "-Xep:FutureReturnValueIgnored:ERROR",
+        "-Xep:GetClassOnEnum:ERROR",
+        "-Xep:ImmutableAnnotationChecker:OFF",
+        "-Xep:ImmutableEnumChecker:ERROR",
+        "-Xep:IncompatibleModifiers:ERROR",
+        "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
+        "-Xep:InputStreamSlowMultibyteRead:ERROR",
+        "-Xep:IterableAndIterator:ERROR",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:ERROR",
+        "-Xep:JUnitAmbiguousTestClass:ERROR",
+        "-Xep:LiteralClassName:ERROR",
         "-Xep:MissingCasesInEnumSwitch:ERROR",
         "-Xep:MissingFail:ERROR",
-        "-Xep:MissingOverride:WARN",
+        "-Xep:MissingOverride:ERROR",
         "-Xep:MutableConstantField:ERROR",
-        "-Xep:NarrowingCompoundAssignment:WARN",
-        "-Xep:NonAtomicVolatileUpdate:WARN",
-        "-Xep:NonOverridingEquals:WARN",
-        "-Xep:NullableConstructor:WARN",
-        "-Xep:NullablePrimitive:WARN",
-        "-Xep:NullableVoid:WARN",
+        "-Xep:NarrowingCompoundAssignment:ERROR",
+        "-Xep:NonAtomicVolatileUpdate:ERROR",
+        "-Xep:NonOverridingEquals:ERROR",
+        "-Xep:NullableConstructor:ERROR",
+        "-Xep:NullablePrimitive:ERROR",
+        "-Xep:NullableVoid:ERROR",
         "-Xep:ObjectToString:ERROR",
         "-Xep:OperatorPrecedence:ERROR",
-        "-Xep:OverridesGuiceInjectableMethod:WARN",
-        "-Xep:PreconditionsInvalidPlaceholder:WARN",
-        "-Xep:ProtoFieldPreconditionsCheckNotNull:WARN",
-        "-Xep:ProtocolBufferOrdinal:WARN",
-        "-Xep:ReferenceEquality:WARN",
-        "-Xep:RequiredModifiers:WARN",
-        "-Xep:ShortCircuitBoolean:WARN",
-        "-Xep:SimpleDateFormatConstant:WARN",
-        "-Xep:StaticGuardedByInstance:WARN",
-        "-Xep:StringEquality:WARN",
-        "-Xep:SynchronizeOnNonFinalField:WARN",
-        "-Xep:TruthConstantAsserts:WARN",
-        "-Xep:TypeParameterShadowing:WARN",
-        "-Xep:TypeParameterUnusedInFormals:WARN",
-        "-Xep:URLEqualsHashCode:WARN",
-        "-Xep:UnsynchronizedOverridesSynchronized:WARN",
+        "-Xep:OverridesGuiceInjectableMethod:ERROR",
+        "-Xep:PreconditionsInvalidPlaceholder:ERROR",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:ERROR",
+        "-Xep:ProtocolBufferOrdinal:ERROR",
+        "-Xep:ReferenceEquality:ERROR",
+        "-Xep:RequiredModifiers:ERROR",
+        "-Xep:ShortCircuitBoolean:ERROR",
+        "-Xep:SimpleDateFormatConstant:ERROR",
+        "-Xep:StaticGuardedByInstance:ERROR",
+        "-Xep:StringEquality:ERROR",
+        "-Xep:SynchronizeOnNonFinalField:ERROR",
+        "-Xep:TruthConstantAsserts:ERROR",
+        "-Xep:TypeParameterShadowing:ERROR",
+        "-Xep:TypeParameterUnusedInFormals:ERROR",
+        "-Xep:URLEqualsHashCode:ERROR",
+        "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
         "-Xep:UnusedException:ERROR",
-        "-Xep:WaitNotInLoop:WARN",
-        "-Xep:WildcardImport:WARN",
+        "-Xep:WaitNotInLoop:ERROR",
+        "-Xep:WildcardImport:ERROR",
     ],
     packages = ["error_prone_packages"],
 )
@@ -98,10 +101,14 @@
         "//javatests/...",
         "//plugins/codemirror-editor/...",
         "//plugins/commit-message-length-validator/...",
+        "//plugins/delete-project/...",
         "//plugins/download-commands/...",
+        "//plugins/gitiles/...",
         "//plugins/hooks/...",
+        "//plugins/plugin-manager/...",
         "//plugins/replication/...",
         "//plugins/reviewnotes/...",
         "//plugins/singleusergroup/...",
+        "//plugins/webhooks/...",
     ],
 )
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 3add025..62b4010 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -61,14 +61,14 @@
         command = " && ".join(cmd),
     )
 
-java_doc = rule(
+_java_doc = rule(
     attrs = {
         "external_docs": attr.string_list(),
         "libs": attr.label_list(allow_files = False),
         "pkgs": attr.string_list(),
         "title": attr.string(),
         "_jdk": attr.label(
-            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
+            default = Label("@bazel_tools//tools/jdk:current_host_java_runtime"),
             allow_files = True,
             providers = [java_common.JavaRuntimeInfo],
         ),
@@ -76,3 +76,16 @@
     outputs = {"zip": "%{name}.zip"},
     implementation = _impl,
 )
+
+def java_doc(**kwargs):
+    libs = kwargs.get("libs", [])
+    libs = libs + select({
+        "//:java11": [],
+        "//:java_next": [],
+        # TODO(davido): Remove this dependency, when Java 8 support is removed.
+        # auto-value generates @javax.annotation.Generated annotation on generated
+        # classes when Java 8 source compatibility level is used, but Java 11 and
+        # later don't have this class any more.
+        "//conditions:default": ["//lib:javax-annotation"],
+    })
+    _java_doc(**dict(kwargs, libs = libs))
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 9160f1d..b428a2d 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -310,6 +310,13 @@
     destdir = ctx.outputs.html.path + ".dir"
     zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
 
+    # We are splitting off the package dir from the app.path such that
+    # we can set the package dir as the root for the bundler, which means
+    # that absolute imports are interpreted relative to that root.
+    pkg_dir = ctx.attr.pkg.lstrip("/")
+    app_path = ctx.file.app.path
+    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
+
     hermetic_npm_binary = " ".join([
         "python",
         "$p/" + ctx.file._run_npm.path,
@@ -320,10 +327,11 @@
         "--strip-comments",
         "--out-file",
         "$p/" + bundled.path,
-        ctx.file.app.path,
+        "--root",
+        pkg_dir,
+        app_path,
     ])
 
-    pkg_dir = ctx.attr.pkg.lstrip("/")
     cmd = " && ".join([
         # unpack dependencies.
         "export PATH",
@@ -480,8 +488,8 @@
         name = name + "_bin",
         compilation_level = "WHITESPACE_ONLY",
         defs = [
-            "--polymer_version=1",
-            "--language_out=ECMASCRIPT6",
+            "--polymer_version=2",
+            "--language_out=ECMASCRIPT_2017",
             "--rewrite_polyfills=false",
         ],
         deps = [
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 1cf82ea..66d7230 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -70,7 +70,6 @@
     # Enforce JDK 8 compatibility on Java 9, see
     # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
     "-Djava.locale.providers=COMPAT,CLDR,SPI",
-    "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED",
 ]
 
 def junit_tests(name, srcs, **kwargs):
@@ -82,7 +81,7 @@
     )
     jvm_flags = kwargs.get("jvm_flags", [])
     jvm_flags = jvm_flags + select({
-        "//:java9": POST_JDK8_OPTS,
+        "//:java11": POST_JDK8_OPTS,
         "//:java_next": POST_JDK8_OPTS,
         "//conditions:default": [],
     })
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index c8ba7ca..68766a3 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -8,6 +8,10 @@
 
 ECLIPSE = "ECLIPSE:"
 
+MAVEN_SNAPSHOT = "https://oss.sonatype.org/content/repositories/snapshots"
+
+SNAPSHOT = "-SNAPSHOT-"
+
 def _maven_release(ctx, parts):
     """induce jar and url name from maven coordinates."""
     if len(parts) not in [3, 4]:
@@ -20,9 +24,25 @@
         group, artifact, version = parts
         file_version = version
 
+    repository = ctx.attr.repository
+
+    if "-SNAPSHOT-" in version:
+        start = version.index(SNAPSHOT)
+        end = start + len(SNAPSHOT) - 1
+
+        # file version without snapshot constant, but with post snapshot suffix
+        file_version = version[:start] + version[end:]
+
+        # version without post snapshot suffix
+        version = version[:end]
+
+        # overwrite the repository with Maven snapshot repository
+        repository = MAVEN_SNAPSHOT
+
     jar = artifact.lower() + "-" + file_version
+
     url = "/".join([
-        ctx.attr.repository,
+        repository,
         group.replace(".", "/"),
         artifact,
         version,
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 649f7da..9915a6e 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -172,13 +172,23 @@
         impl = xml.dom.minidom.getDOMImplementation()
         return impl.createDocument(None, 'classpath', None)
 
-    def classpathentry(kind, path, src=None, out=None, exported=None):
+    def import_jgit_sources():
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit/resources')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.archive/src',
+            excluding='org/eclipse/jgit/archive/FormatActivator.java')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.archive/resources')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
+
+    def classpathentry(kind, path, src=None, out=None, exported=None, excluding=None):
         e = doc.createElement('classpathentry')
         e.setAttribute('kind', kind)
         # Excluding the BUILD file, to avoid the Eclipse warnings:
         # "The resource is a duplicate of ..."
         if kind == 'src':
-            e.setAttribute('excluding', '**/BUILD')
+            e.setAttribute('excluding', '**/BUILD' if not excluding else excluding)
         e.setAttribute('path', path)
         if src:
             e.setAttribute('sourcepath', src)
@@ -193,7 +203,7 @@
             testAtt.setAttribute('name', 'test')
             testAtt.setAttribute('value', 'true')
             atts.appendChild(testAtt)
-        if "apt_generated" in path:
+        if "apt_generated" in path or "modules/jgit" in path:
             if not atts:
                 atts = doc.createElement('attributes')
             ignoreOptionalProblems = doc.createElement('attribute')
@@ -216,6 +226,7 @@
 
     # Classpath entries are absolute for cross-cell support
     java_library = re.compile('bazel-out/.*?-fastbuild/bin/(.*)/[^/]+[.]jar$')
+    proto_library = re.compile('bazel-out/.*?-fastbuild/bin/(.*)proto/(.*)_proto-speed[.]jar$')
     srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
     for p in _query_classpath(MAIN):
         if p.endswith('-src.jar'):
@@ -230,11 +241,7 @@
                p.endswith('com_google_protobuf/libprotobuf_java.jar') or \
                p.endswith('lucene-core-and-backward-codecs-merged_deploy.jar'):
                 lib.add(p)
-            # JGit dependency from external repository
-            if 'gerrit-' not in p and 'jgit' in p:
-                lib.add(p)
-            # Assume any jars in /proto/ are from java_proto_library rules
-            if '/bin/proto/' in p:
+            if proto_library.match(p) :
                 proto.add(p)
         else:
             # Don't mess up with Bazel internal test runner dependencies.
@@ -249,6 +256,7 @@
     classpathentry('src', 'java')
     classpathentry('src', 'javatests', out='eclipse-out/test')
     classpathentry('src', 'resources')
+    import_jgit_sources()
     for s in sorted(src):
         out = None
 
@@ -296,8 +304,8 @@
                 classpathentry('lib', j, s)
 
     for p in sorted(proto):
-        s = p.replace('-fastbuild/bin/proto/lib', '-fastbuild/genfiles/proto/')
-        s = p.replace('-fastbuild/bin/proto/testing/lib', '-fastbuild/genfiles/proto/testing/')
+        s = p.replace('/proto/lib', '/proto/')
+        s = s.replace('/proto/testing/lib', '/proto/testing/')
         s = s.replace('.jar', '-src.jar')
         classpathentry('lib', p, s)
 
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index c4cc0fd..95bf075 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.0.9-SNAPSHOT</version>
+  <version>3.1.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ecc9b25..cce5621 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.0.9-SNAPSHOT</version>
+  <version>3.1.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 01d7357..29f3d09 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.0.9-SNAPSHOT</version>
+  <version>3.1.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index d90c16f..622209a 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.0.9-SNAPSHOT</version>
+  <version>3.1.5-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 468eac25..5e2e4e1 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -17,38 +17,46 @@
     # Transitive dependency of commons-compress
     maven_jar(
         name = "tukaani-xz",
-        artifact = "org.tukaani:xz:1.6",
-        sha1 = "05b6f921f1810bdf90e25471968f741f87168b64",
+        artifact = "org.tukaani:xz:1.8",
+        sha1 = "c4f7d054303948eb6a4066194253886c8af07128",
     )
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.0.5",
-        sha1 = "b81ef162970cdb9f4512ee2da09715a856ff4c4c",
+        artifact = "io.dropwizard.metrics:metrics-core:4.0.7",
+        sha1 = "673899f605f52ca35836673ccfee97154a496a61",
     )
 
+    SSHD_VERS = "2.3.0"
+
     maven_jar(
         name = "sshd",
-        artifact = "org.apache.sshd:sshd-core:2.0.0",
-        sha1 = "f4275079a2463cfd2bf1548a80e1683288a8e86b",
+        artifact = "org.apache.sshd:sshd-core:" + SSHD_VERS,
+        sha1 = "21aeea9deba96c9b81ea0935fa4fac61aa3cf646",
+    )
+
+    maven_jar(
+        name = "sshd-common",
+        artifact = "org.apache.sshd:sshd-common:" + SSHD_VERS,
+        sha1 = "8b6e3baaa0d35b547696965eef3e62477f5e74c9",
     )
 
     maven_jar(
         name = "eddsa",
-        artifact = "net.i2p.crypto:eddsa:0.2.0",
-        sha1 = "0856a92559c4daf744cb27c93cd8b7eb1f8c4780",
+        artifact = "net.i2p.crypto:eddsa:0.3.0",
+        sha1 = "1901c8d4d8bffb7d79027686cfb91e704217c3e1",
     )
 
     maven_jar(
         name = "mina-core",
-        artifact = "org.apache.mina:mina-core:2.0.17",
-        sha1 = "7e10ec974760436d931f3e58be507d1957bcc8db",
+        artifact = "org.apache.mina:mina-core:2.0.21",
+        sha1 = "e1a317689ecd438f54e863747e832f741ef8e092",
     )
 
     maven_jar(
         name = "sshd-mina",
-        artifact = "org.apache.sshd:sshd-mina:2.0.0",
-        sha1 = "50f2669312494f6c1996d8bd0d266c1fca7be6f6",
+        artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
+        sha1 = "55dc0830dfcbceba01f9460812ee454978a15fe8",
     )
 
     # elasticsearch-rest-client explicitly depends on this version
@@ -118,50 +126,6 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    POWERM_VERS = "1.6.1"
-
-    maven_jar(
-        name = "powermock-module-junit4",
-        artifact = "org.powermock:powermock-module-junit4:" + POWERM_VERS,
-        sha1 = "ea8530b2848542624f110a393513af397b37b9cf",
-    )
-
-    maven_jar(
-        name = "powermock-module-junit4-common",
-        artifact = "org.powermock:powermock-module-junit4-common:" + POWERM_VERS,
-        sha1 = "7222ced54dabc310895d02e45c5428ca05193cda",
-    )
-
-    maven_jar(
-        name = "powermock-reflect",
-        artifact = "org.powermock:powermock-reflect:" + POWERM_VERS,
-        sha1 = "97d25eda8275c11161bcddda6ef8beabd534c878",
-    )
-
-    maven_jar(
-        name = "powermock-api-easymock",
-        artifact = "org.powermock:powermock-api-easymock:" + POWERM_VERS,
-        sha1 = "aa740ecf89a2f64d410b3d93ef8cd6833009ef00",
-    )
-
-    maven_jar(
-        name = "powermock-api-support",
-        artifact = "org.powermock:powermock-api-support:" + POWERM_VERS,
-        sha1 = "592ee6d929c324109d3469501222e0c76ccf0869",
-    )
-
-    maven_jar(
-        name = "powermock-core",
-        artifact = "org.powermock:powermock-core:" + POWERM_VERS,
-        sha1 = "5afc1efce8d44ed76b30af939657bd598e45d962",
-    )
-
-    maven_jar(
-        name = "javassist",
-        artifact = "org.javassist:javassist:3.22.0-GA",
-        sha1 = "3e83394258ae2089be7219b971ec21a8288528ad",
-    )
-
     TESTCONTAINERS_VERSION = "1.14.2"
 
     maven_jar(
diff --git a/version.bzl b/version.bzl
index e40acdd..4b32573 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.0.9-SNAPSHOT"
+GERRIT_VERSION = "3.1.5-SNAPSHOT"